Saturday, December 6, 2025

SOLID Principles Explained — Practical Java Examples & Best Practices

 

SOLID Principles Explained — Practical Java Examples & Best Practices

Author: Venugopal (CodeWithVenu) • Category: System Design • Java • Best Practices

SOLID is an acronym for five OO design principles that make code more maintainable, extensible and testable. This guide explains each principle with Java examples, typical violations, and how to refactor code the right way.


Contents

  1. What is SOLID?
  2. S — Single Responsibility Principle (SRP)
  3. O — Open/Closed Principle (OCP)
  4. L — Liskov Substitution Principle (LSP)
  5. I — Interface Segregation Principle (ISP)
  6. D — Dependency Inversion Principle (DIP)
  7. Putting It Together: Practical Tips
  8. Summary & Further Reading

What is SOLID?

SOLID is a set of five design principles coined by Robert C. Martin (Uncle Bob). They are guidelines for writing object-oriented code that is easier to maintain, extend, and test:

  • S — Single Responsibility Principle
  • O — Open/Closed Principle
  • L — Liskov Substitution Principle
  • I — Interface Segregation Principle
  • D — Dependency Inversion Principle

Together they reduce coupling and increase cohesion — leading to robust, modular systems.


S — Single Responsibility Principle (SRP)

Definition: A class should have only one reason to change — i.e., one responsibility.

Why it matters

When a class has multiple responsibilities, changes in one concern can break or affect others. SRP improves maintainability and makes testing easier.

Violation example (Java)


// UserService mixes business logic + persistence + reporting (bad)
public class UserService {
    public void createUser(User u) {
        // validate
        // save to DB
    }

    public void generateUserReport(User u) {
        // accumulate data
        // create PDF
    }
}
    

Refactor to respect SRP

Split responsibilities into focused classes:


// responsibility: business logic only
public class UserService {
    private final UserRepository repo;
    public UserService(UserRepository repo) { this.repo = repo; }

    public void createUser(User u) {
        // validate
        repo.save(u);
    }
}

// responsibility: persistence
public interface UserRepository {
    void save(User u);
}

// responsibility: reporting
public class UserReportGenerator {
    public byte[] generatePdf(User u) { /* ... */ return new byte[0]; }
}
    

Practical rule of thumb

  • If you have to change a class for more than one reason, split it.
  • Each class should map to a single concept or role.

O — Open/Closed Principle (OCP)

Definition: Software entities (classes, modules, functions) should be open for extension, but closed for modification.

Why it matters

OCP prevents you from repeatedly editing existing tested code when adding new behavior. Instead, you extend via new code (subclasses, strategy implementations, plugins).

Violation example


// Every time a new discount type appears, you edit this class (bad)
public class PriceCalculator {
    public double calculate(Order o) {
        if (o.getType() == OrderType.REGULAR) return basePrice(o);
        if (o.getType() == OrderType.SEASONAL) return basePrice(o) * 0.9;
        if (o.getType() == OrderType.PROMO) return basePrice(o) * 0.8;
        // add more branches -> modify class
    }
}
    

Refactor using Strategy Pattern (good)


// Strategy interface
public interface PricingStrategy {
    double apply(Order order);
}

// Concrete implementations
public class RegularPricing implements PricingStrategy { public double apply(Order o){ return base(o);} }
public class SeasonalPricing implements PricingStrategy { public double apply(Order o){ return base(o) * 0.9;} }

// Calculator depends on the interface (closed for modification)
public class PriceCalculator {
    private final PricingStrategy strategy;
    public PriceCalculator(PricingStrategy strategy) { this.strategy = strategy; }
    public double calculate(Order o) { return strategy.apply(o); }
}
    

Practical tips

  • Prefer composition over modifying existing classes.
  • Use interfaces/abstract classes and dependency injection.

L — Liskov Substitution Principle (LSP)

Definition: Subtypes must be substitutable for their base types without breaking correctness.

Why it matters

LSP ensures that clients programmed against an abstraction don't need to know about concrete subclass quirks. Violations often occur when subclasses weaken preconditions or change expected behavior.

Violation example


// Shape hierarchy: a naive Square extends Rectangle might break LSP
public class Rectangle {
    protected int width, height;
    public void setWidth(int w) { width = w; }
    public void setHeight(int h) { height = h; }
    public int getArea() { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int w) { width = w; height = w; } // changes behavior
    @Override
    public void setHeight(int h) { height = h; width = h; }
}
// A client expecting Rectangle semantics may behave incorrectly with Square
    

Refactor (use composition or separate abstractions)


// Option A: Separate interfaces
public interface Shape { int getArea(); }
public class Rectangle implements Shape { /* width, height */ }
public class Square implements Shape { /* side */ }

// Option B: Use factory methods or composition to avoid substituting Rectangle with Square
    

Practical tips

  • Ensure subclass methods meet base class contracts.
  • Document invariants and expected behavior in interfaces.

I — Interface Segregation Principle (ISP)

Definition: Clients should not be forced to depend on interfaces they do not use. Create small, role-specific interfaces rather than a large “fat” interface.

Why it matters

ISP avoids bloated implementations and prevents coupling clients to unnecessary members.

Violation example


// Fat interface with many methods (bad)
public interface Printer {
    void print(Document d);
    void scan(Document d);
    void fax(Document d);
}
public class SimplePrinter implements Printer {
    public void print(Document d) { /* ok */ }
    public void scan(Document d) { throw new UnsupportedOperationException(); } // bad
    public void fax(Document d) { throw new UnsupportedOperationException(); }
}
    

Refactor to smaller interfaces (good)


// Segregated interfaces
public interface Printer { void print(Document d); }
public interface Scanner { void scan(Document d); }
public interface Fax { void fax(Document d); }

public class SimplePrinter implements Printer {
    public void print(Document d) { /* ok */ }
}
    

Practical tips

  • Design interfaces for specific client roles.
  • Favor multiple focused interfaces instead of one general interface.

D — Dependency Inversion Principle (DIP)

Definition: High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions.

Why it matters

DIP reduces coupling between layers and makes it easier to replace low-level implementations (e.g., swapping databases, external services, mocks for testing).

Violation example


// High-level module depends on low-level concrete class (bad)
public class OrderService {
    private final MySqlOrderRepository repo = new MySqlOrderRepository();
    public void submit(Order o) { repo.save(o); }
}
    

Refactor using interfaces + DI (good)


// Abstraction
public interface OrderRepository { void save(Order o); }

// Low-level implementation
public class MySqlOrderRepository implements OrderRepository {
    public void save(Order o) { /* JDBC or JPA */ }
}

// High-level depends on abstraction (inject implementation)
public class OrderService {
    private final OrderRepository repo;
    public OrderService(OrderRepository repo) { this.repo = repo; }
    public void submit(Order o) { repo.save(o); }
}
// Wiring via DI framework or factory:
OrderRepository repo = new MySqlOrderRepository();
OrderService svc = new OrderService(repo);
    

Practical tips

  • Use constructor injection for mandatory dependencies.
  • Use dependency injection frameworks (Spring, Guice) to wire implementations.
  • Interfaces improve testability — swap real implementations with mocks or in-memory variants.

Putting It Together: Practical Tips & Patterns

  • Favor small classes & focused interfaces: SRP + ISP lead you to smaller, composable components.
  • Use patterns wisely: Strategy, Template Method, Adapter, Decorator — these often help implement OCP and DIP.
  • Prefer composition over inheritance: Composition reduces LSP issues and improves flexibility.
  • Keep contracts explicit: Document method invariants, exceptions, and expected behavior to avoid LSP pitfalls.
  • Write tests first (or early): Unit tests reveal violations of LSP and DIP quickly. Use mocks for dependencies.
  • Refactor incrementally: When adding features, try to extend via new classes or strategy implementations (OCP) instead of editing existing ones.
  • Avoid premature abstraction: Don’t generalize too early — implement just the abstractions you need and refactor when patterns actually emerge.

Example: Combining SOLID (small demo)


// Abstraction
public interface NotificationSender { void send(Notification n); }

// Concrete implementations
public class EmailSender implements NotificationSender { public void send(Notification n) { /* smtp */ } }
public class SmsSender implements NotificationSender { public void send(Notification n) { /* sms api */ } }

// High-level service depends on abstraction (DIP)
public class NotificationService {
    private final NotificationSender sender; // DIP
    public NotificationService(NotificationSender sender) { this.sender = sender; } // SRP
    public void notify(User u, String message) {
        Notification n = new Notification(u.getContact(), message);
        sender.send(n); // OCP - can extend with new senders
    }
}
    

Testing & Maintainability

Applying SOLID makes unit testing straightforward:

  • DIP + dependency injection allow injecting mocks in tests.
  • SRP reduces the need for complex test fixtures — one responsibility = focused tests.
  • ISP lets you mock only the methods needed by a client (no unnecessary sudo-implementations).

Example (JUnit + Mockito)


// Test NotificationService with mock sender
@ExtendWith(MockitoExtension.class)
public class NotificationServiceTest {
    @Mock NotificationSender sender;
    @InjectMocks NotificationService svc;

    @Test
    void shouldSendNotification() {
        User u = new User("alice", "alice@example.com");
        svc.notify(u, "Hello");
        verify(sender, times(1)).send(any(Notification.class));
    }
}
    

Common Pitfalls & Misuse

  • Over-abstraction: Creating interfaces for everything too early leads to complexity. Refactor to abstractions as needs grow.
  • Blindly following rules: SOLID are guidelines — use judgement. Trade-offs exist, especially in small apps where simplicity matters more than perfect separation.
  • Violating LSP via inheritance: Inheritance can cause subtle behavioral breakages — prefer composition unless subclass truly is a subtype.
  • Fat interfaces: One-size-fits-all interfaces force clients to implement methods they don’t need — use ISP to split them.

Summary

SOLID principles help you write code that is easier to maintain, test, and extend. They work best when applied pragmatically:

  • SRP keeps classes focused.
  • OCP encourages extension over modification.
  • LSP preserves correct substitutability.
  • ISP avoids bloated interfaces.
  • DIP decouples high-level logic from low-level implementations.

Start small: refactor one class at a time, add tests, and prefer composition and DI. Over time your codebase will become clearer and safer to change.

Further reading

  • “Clean Architecture” — Robert C. Martin
  • “Clean Code” — Robert C. Martin
  • Design Patterns (Gang of Four)

No comments:

Post a Comment

Java Object-Oriented Design (OOD)