SOLID Principles: The Difference Between Code You Love and Code You Fear
We’ve all been there.
You open a class to make a tiny change. Twenty minutes later you’re tracing method calls across half the codebase, questioning every life decision that led you here.
Most of the time, the problem isn’t Java. It’s design.
That’s where SOLID comes in. These five principles won’t magically make your code perfect, but they’ll dramatically reduce the amount of pain future-you has to deal with.
Let’s go through them one by one.
S — Single Responsibility Principle
A class should do one thing and do it well.
If a class has multiple jobs, eventually those jobs will start stepping on each other’s toes.
Here’s a common example:
public class UserService {
public void createUser(User user) {
// Save user to database
}
public void sendWelcomeEmail(User user) {
// Send email
}
}
At first this seems harmless.
But now this class is responsible for:
- User management
- Email delivery
What happens when the email system changes? You modify UserService.
What happens when user creation changes? You modify UserService.
Two reasons to change = one unhappy class.
A better approach:
public class UserService {
public void createUser(User user) {
// Save user
}
}
public class EmailService {
public void sendWelcomeEmail(User user) {
// Send email
}
}
Now each class has a clear purpose.
Simple classes are easier to understand, test, and maintain.
O — Open/Closed Principle
Your code should be open for extension but closed for modification.
Translation:
Adding new functionality shouldn’t require opening an existing class and stuffing another if statement into it.
Consider this:
public class PaymentProcessor {
public void process(String type) {
if ("CARD".equals(type)) {
System.out.println("Processing card");
}
if ("PAYPAL".equals(type)) {
System.out.println("Processing PayPal");
}
}
}
Looks fine today.
Then your boss wants Apple Pay.
Then Google Pay.
Then crypto because apparently we’re doing that now.
Every new payment method means editing the same class.
Instead:
public interface PaymentMethod {
void process();
}
public class CreditCardPayment implements PaymentMethod {
@Override
public void process() {
System.out.println("Processing card payment");
}
}
public class PayPalPayment implements PaymentMethod {
@Override
public void process() {
System.out.println("Processing PayPal payment");
}
}
public class PaymentProcessor {
public void process(PaymentMethod paymentMethod) {
paymentMethod.process();
}
}
Need Apple Pay?
Create another implementation.
No existing code needs to change.
L — Liskov Substitution Principle
This one sounds scary but the idea is simple:
If something is a subtype of another thing, it should behave like one.
Let’s break it.
public class Bird {
public void fly() {
System.out.println("Flying");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException();
}
}
The compiler is happy.
Your users won’t be.
If code receives a Bird, it expects a bird that can fly.
A penguin breaks that expectation.
A better design:
public interface Bird {
void eat();
}
public interface FlyingBird {
void fly();
}
public class Eagle implements Bird, FlyingBird {
@Override
public void eat() {}
@Override
public void fly() {
System.out.println("Flying");
}
}
public class Penguin implements Bird {
@Override
public void eat() {}
}
Now every type behaves exactly as expected.
No surprises.
No runtime explosions.
I — Interface Segregation Principle
Nobody likes being forced to do work they don’t need.
Classes don’t either.
Consider this interface:
public interface Worker {
void work();
void eat();
}
Now let’s create a robot.
public class Robot implements Worker {
@Override
public void work() {
System.out.println("Working");
}
@Override
public void eat() {
throw new UnsupportedOperationException();
}
}
Robots don’t eat.
Yet we’re forcing them to pretend they do.
Instead, split the interface:
public interface Workable {
void work();
}
public interface Eatable {
void eat();
}
public class Human implements Workable, Eatable {
@Override
public void work() {}
@Override
public void eat() {}
}
public class Robot implements Workable {
@Override
public void work() {}
}
Much cleaner.
Interfaces should be small and focused.
Not giant contracts everyone hates implementing.
D — Dependency Inversion Principle
This is probably the principle you’ll encounter most in modern Java frameworks.
The idea:
Depend on abstractions, not concrete implementations.
Here’s the tightly-coupled version:
public class FileLogger {
public void log(String message) {
System.out.println(message);
}
}
public class UserService {
private final FileLogger logger = new FileLogger();
public void createUser() {
logger.log("User created");
}
}
UserService is now married to FileLogger.
Changing the logger means changing the service.
Instead:
public interface Logger {
void log(String message);
}
public class FileLogger implements Logger {
@Override
public void log(String message) {
System.out.println(message);
}
}
public class UserService {
private final Logger logger;
public UserService(Logger logger) {
this.logger = logger;
}
public void createUser() {
logger.log("User created");
}
}
Now UserService doesn’t care whether logs go to:
- A file
- A database
- Kafka
- The void
As long as it gets a Logger, it’s happy.
That’s flexibility.
Final Thoughts
SOLID isn’t about writing more code.
It’s about writing code that survives contact with reality.
If you remember only one thing, remember this:
Most code starts clean.
The challenge is keeping it clean after six months of feature requests, bug fixes, deadlines, and “quick changes” that somehow become permanent.
SOLID gives you a fighting chance.
And future-you will be very grateful.