Skip to main content

SOLID Principles Explained with Java Examples

· 8 min read
Hieu Nguyen
Senior Software Engineer at OCB

A clear explanation of the five SOLID principles with practical Java examples — the foundation of clean, maintainable, and scalable object-oriented design.

SOLID is an acronym for five design principles that help us write code that is easy to understand, maintain, and extend. Whether you're preparing for an interview or refactoring production code, mastering SOLID will level up your software design.

LetterPrincipleOne-Liner
SSingle ResponsibilityA class should have only one reason to change
OOpen/ClosedOpen for extension, closed for modification
LLiskov SubstitutionSubtypes must be substitutable for their base types
IInterface SegregationDon't force clients to depend on methods they don't use
DDependency InversionDepend on abstractions, not concrete implementations

S — Single Responsibility Principle (SRP)

A class should have only one reason to change.

Each class should focus on doing one thing well. If a class handles multiple concerns, changes in one area can break another.

❌ Bad Example

public class UserService {

public void registerUser(String email, String password) {
// Validate input
if (!email.contains("@")) {
throw new IllegalArgumentException("Invalid email");
}

// Save to database
String sql = "INSERT INTO users (email, password) VALUES (?, ?)";
jdbcTemplate.update(sql, email, hashPassword(password));

// Send welcome email
emailClient.send(email, "Welcome!", "Thanks for signing up!");

// Write audit log
logger.info("New user registered: " + email);
}
}

This single method handles validation, persistence, email, and logging — four reasons to change.

✅ Good Example

public class UserValidator {
public void validate(String email, String password) {
if (!email.contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
if (password.length() < 8) {
throw new IllegalArgumentException("Password too short");
}
}
}

public class UserRepository {
public void save(User user) {
String sql = "INSERT INTO users (email, password) VALUES (?, ?)";
jdbcTemplate.update(sql, user.getEmail(), user.getPassword());
}
}

public class EmailService {
public void sendWelcomeEmail(String email) {
emailClient.send(email, "Welcome!", "Thanks for signing up!");
}
}

public class UserService {
private final UserValidator validator;
private final UserRepository repository;
private final EmailService emailService;

public UserService(UserValidator validator, UserRepository repository, EmailService emailService) {
this.validator = validator;
this.repository = repository;
this.emailService = emailService;
}

public void registerUser(String email, String password) {
validator.validate(email, password);
User user = new User(email, hashPassword(password));
repository.save(user);
emailService.sendWelcomeEmail(email);
}
}

Now each class has one responsibility, and changes to email logic won't affect database code.


O — Open/Closed Principle (OCP)

Classes should be open for extension but closed for modification.

You should be able to add new behavior without changing existing code. This is typically achieved through abstraction and polymorphism.

❌ Bad Example

public class PaymentProcessor {

public void processPayment(String type, double amount) {
if (type.equals("CREDIT_CARD")) {
// process credit card
System.out.println("Charging credit card: $" + amount);
} else if (type.equals("PAYPAL")) {
// process PayPal
System.out.println("Charging PayPal: $" + amount);
} else if (type.equals("BANK_TRANSFER")) {
// process bank transfer
System.out.println("Processing bank transfer: $" + amount);
}
// Every new payment method = modify this class ❌
}
}

✅ Good Example

public interface PaymentMethod {
void pay(double amount);
}

public class CreditCardPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Charging credit card: $" + amount);
}
}

public class PayPalPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Charging PayPal: $" + amount);
}
}

public class BankTransferPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Processing bank transfer: $" + amount);
}
}

// Adding MoMo payment? Just create a new class — no modification needed ✅
public class MoMoPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Charging MoMo e-wallet: $" + amount);
}
}

public class PaymentProcessor {
public void processPayment(PaymentMethod method, double amount) {
method.pay(amount);
}
}

Adding a new payment method only requires creating a new class — zero changes to PaymentProcessor.


L — Liskov Substitution Principle (LSP)

Subtypes must be substitutable for their base types without altering the correctness of the program.

If class B extends class A, you should be able to use B anywhere A is expected without unexpected behavior.

❌ Bad Example — The Classic Rectangle Problem

public class Rectangle {
protected int width;
protected int height;

public void setWidth(int width) { this.width = width; }
public void setHeight(int height) { this.height = height; }
public int getArea() { return width * height; }
}

public class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width; // Breaks parent's contract!
}

@Override
public void setHeight(int height) {
this.width = height; // Breaks parent's contract!
this.height = height;
}
}

// This test passes for Rectangle but FAILS for Square
void testArea(Rectangle rect) {
rect.setWidth(5);
rect.setHeight(4);
assert rect.getArea() == 20; // ❌ Square returns 16!
}

✅ Good Example

public interface Shape {
int getArea();
}

public class Rectangle implements Shape {
private final int width;
private final int height;

public Rectangle(int width, int height) {
this.width = width;
this.height = height;
}

@Override
public int getArea() { return width * height; }
}

public class Square implements Shape {
private final int side;

public Square(int side) {
this.side = side;
}

@Override
public int getArea() { return side * side; }
}

// Both work correctly as Shape ✅
void printArea(Shape shape) {
System.out.println("Area: " + shape.getArea());
}

By using a common interface and immutable objects, Square and Rectangle are both valid Shape implementations without surprising behavior.


I — Interface Segregation Principle (ISP)

Clients should not be forced to depend on methods they don't use.

Break large interfaces into smaller, focused ones so that implementing classes only need to provide what's relevant to them.

❌ Bad Example

public interface Worker {
void work();
void eat();
void sleep();
void attendMeeting();
}

// A Robot doesn't eat or sleep!
public class Robot implements Worker {
@Override
public void work() { System.out.println("Working..."); }

@Override
public void eat() { /* Not applicable ❌ */ }

@Override
public void sleep() { /* Not applicable ❌ */ }

@Override
public void attendMeeting() { /* Not applicable ❌ */ }
}

Robot is forced to implement methods that make no sense for it.

✅ Good Example

public interface Workable {
void work();
}

public interface Feedable {
void eat();
}

public interface Restable {
void sleep();
}

public interface Meetable {
void attendMeeting();
}

// Human implements all relevant interfaces
public class HumanWorker implements Workable, Feedable, Restable, Meetable {
@Override
public void work() { System.out.println("Working..."); }

@Override
public void eat() { System.out.println("Eating lunch..."); }

@Override
public void sleep() { System.out.println("Sleeping..."); }

@Override
public void attendMeeting() { System.out.println("In a meeting..."); }
}

// Robot only implements what it needs ✅
public class Robot implements Workable {
@Override
public void work() { System.out.println("Robot working 24/7..."); }
}

Each interface is small and focused. Classes only implement what they actually need.


D — Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions.

Don't hardcode dependencies to concrete classes. Instead, depend on interfaces and inject implementations.

❌ Bad Example

public class MySQLUserRepository {
public User findById(Long id) {
// MySQL-specific query
return jdbcTemplate.queryForObject("SELECT * FROM users WHERE id = ?", mapper, id);
}
}

public class UserService {
// Tightly coupled to MySQL ❌
private final MySQLUserRepository repository = new MySQLUserRepository();

public User getUser(Long id) {
return repository.findById(id);
}
}

UserService is hardcoded to MySQL. Switching to PostgreSQL or adding a cache layer requires modifying UserService.

✅ Good Example

// Abstraction
public interface UserRepository {
User findById(Long id);
void save(User user);
}

// Low-level implementation 1
public class MySQLUserRepository implements UserRepository {
@Override
public User findById(Long id) {
return jdbcTemplate.queryForObject("SELECT * FROM users WHERE id = ?", mapper, id);
}

@Override
public void save(User user) {
jdbcTemplate.update("INSERT INTO users ...", user.getEmail());
}
}

// Low-level implementation 2
public class PostgresUserRepository implements UserRepository {
@Override
public User findById(Long id) {
// PostgreSQL-specific implementation
}

@Override
public void save(User user) {
// PostgreSQL-specific implementation
}
}

// High-level module depends on abstraction ✅
public class UserService {
private final UserRepository repository;

// Inject via constructor
public UserService(UserRepository repository) {
this.repository = repository;
}

public User getUser(Long id) {
return repository.findById(id);
}
}

With Spring Boot, dependency injection is automatic:

@Service
public class UserService {
private final UserRepository repository;

@Autowired
public UserService(UserRepository repository) {
this.repository = repository;
}
}

// Spring will inject the right implementation based on your config/profile

Switching databases is now a config change, not a code change.


SOLID in Practice — How They Work Together

Here's a real-world example combining all five principles — a notification service:

// ISP — Small, focused interfaces
public interface NotificationSender {
void send(String recipient, String message);
boolean supports(String channel);
}

// OCP — New channels = new class, no modification
public class EmailSender implements NotificationSender {
@Override
public void send(String recipient, String message) {
// Send email via SMTP
}

@Override
public boolean supports(String channel) {
return "EMAIL".equals(channel);
}
}

public class SmsSender implements NotificationSender {
@Override
public void send(String recipient, String message) {
// Send SMS via Twilio
}

@Override
public boolean supports(String channel) {
return "SMS".equals(channel);
}
}

// SRP — Only responsible for routing notifications
// DIP — Depends on NotificationSender abstraction
public class NotificationService {
private final List<NotificationSender> senders;

// DIP — Inject all senders
public NotificationService(List<NotificationSender> senders) {
this.senders = senders;
}

public void notify(String channel, String recipient, String message) {
senders.stream()
.filter(s -> s.supports(channel))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported channel: " + channel))
.send(recipient, message);
}
}
  • SRP: NotificationService only routes, each sender only sends via one channel.
  • OCP: Add Slack notifications by creating SlackSender — zero changes to existing code.
  • LSP: All senders are interchangeable via NotificationSender.
  • ISP: The interface is small and specific.
  • DIP: The service depends on the NotificationSender abstraction.

Key Takeaways

  1. SRP — Keep classes focused. If you describe a class with "and", it probably does too much.
  2. OCP — Use interfaces and polymorphism so new features mean new classes, not if/else chains.
  3. LSP — Subclasses should never surprise the caller. If it looks like a duck, it should quack like one.
  4. ISP — Prefer many small interfaces over one fat interface.
  5. DIP — Always program to an interface. Use constructor injection in Spring Boot.

Remember: SOLID principles are guidelines, not dogma. Apply them pragmatically — over-engineering a simple CRUD app with 10 interfaces is worse than a slightly coupled solution. Use SOLID when the codebase grows and complexity demands it.


Thanks for reading! I hope these examples help you apply SOLID principles in your own Java projects. 🚀