Skip to content

SOLID Principles

In Hands-on 1 AccountService starts small: create, read, delete. Then someone adds email validation, a welcome email, an audit log, a discount rule per customer type, an admin bulk import… Six months later the class has 600 lines, every change breaks an unrelated test, and nobody can unit-test it without a database and an SMTP server. Nothing here is an architecture problem yet β€” it is a class design problem, and SOLID is the vocabulary for it.

SOLID is an acronym for five object-oriented design principles collected by Robert C. Martin1. He first described them together in his 2000 paper Design Principles and Design Patterns and in the book Agile Software Development: Principles, Patterns, and Practices (2002); the acronym itself was suggested by Michael Feathers around 2004. These principles operate at the class and module level β€” they are the building blocks that Clean Architecture and Hexagonal Architecture enforce at the system level.

Letter Principle Coined by
S Single Responsibility Principle Robert C. Martin
O Open/Closed Principle Bertrand Meyer, 19882
L Liskov Substitution Principle Barbara Liskov, 1987; formalised with Jeannette Wing in 19943
I Interface Segregation Principle Robert C. Martin
D Dependency Inversion Principle Robert C. Martin
flowchart LR
    S["S β€” Single Responsibility\nOne reason to change"] -->|composes into| arch
    O["O β€” Open / Closed\nExtend, don't modify"] -->|composes into| arch
    L["L β€” Liskov Substitution\nSubtypes honour contracts"] -->|composes into| arch
    I["I β€” Interface Segregation\nNo forced dependencies"] -->|composes into| arch
    D["D β€” Dependency Inversion\nDepend on abstractions"] -->|composes into| arch
    arch["Clean / Hexagonal\nArchitecture"]:::highlight
    classDef highlight fill:#fcc

S β€” Single Responsibility Principle

"A class should have one, and only one, reason to change." β€” Robert C. Martin

A class is responsible to one actor4. An actor is a group of users or stakeholders who care about the same concern. If a class serves two actors, changes requested by one risk breaking functionality for the other.

What SRP does NOT mean

SRP does not mean a class should do only one thing. It means a class should have one reason to change β€” one owner. A service class may orchestrate several steps, but if all those steps serve the same business concern and the same stakeholder, the class has a single responsibility.

AccountService.java β€” too many concerns
// VIOLATION: one class handles validation, persistence, email, and audit
// Every time any of these concerns changes, this class must be modified.
public class AccountService {

    private final DataSource dataSource;
    private final JavaMailSender mailSender;
    private final Logger logger = Logger.getLogger(AccountService.class.getName());

    public AccountService(DataSource dataSource, JavaMailSender mailSender) {
        this.dataSource = dataSource;
        this.mailSender = mailSender;
    }

    public void createAccount(String email, String password) {

        // 1. Validation β€” business concern
        if (email == null || !email.contains("@"))
            throw new IllegalArgumentException("Invalid email: " + email);
        if (password == null || password.length() < 8)
            throw new IllegalArgumentException("Password too short");

        // 2. Persistence β€” infrastructure concern
        try (Connection conn = dataSource.getConnection()) {
            PreparedStatement stmt = conn.prepareStatement(
                "INSERT INTO accounts (email, password_hash) VALUES (?, ?)");
            stmt.setString(1, email);
            stmt.setString(2, BCrypt.hashpw(password, BCrypt.gensalt()));
            stmt.execute();
        } catch (SQLException e) {
            throw new RuntimeException("Failed to persist account", e);
        }

        // 3. Email notification β€” messaging concern
        SimpleMailMessage msg = new SimpleMailMessage();
        msg.setTo(email);
        msg.setSubject("Welcome to the Platform!");
        msg.setText("Your account has been created. You can now log in.");
        mailSender.send(msg);

        // 4. Audit log β€” cross-cutting concern
        logger.info("Account created for email=" + email
            + " at=" + Instant.now());
    }
}

This class has four reasons to change: a different validation rule, a database schema change, a new email template, or a different audit format β€” all require modifying the same file.

AccountService.java β€” focused on orchestration
// Single responsibility: orchestrate the account-creation use case.
// Validation, persistence, email, and audit are delegated to specialists.
public class AccountService {

    private final AccountRepository repository;
    private final EmailService emailService;
    private final AuditService auditService;

    public AccountService(AccountRepository repository,
                          EmailService emailService,
                          AuditService auditService) {
        this.repository   = repository;
        this.emailService = emailService;
        this.auditService = auditService;
    }

    public Account create(AccountIn in) {
        Account account = Account.of(in);           // validation inside domain object
        repository.save(account);                   // persistence
        emailService.sendWelcome(account.email());  // notification
        auditService.log("account.created", account.id()); // audit
        return account;
    }
}

Each specialist class changes for its own reason only. AccountService changes only when the account-creation workflow changes.

flowchart LR
    subgraph bad ["❌ Before β€” one class, many concerns"]
        as["AccountService\nβ€’ validate\nβ€’ persist\nβ€’ send email\nβ€’ audit"]
    end
    subgraph good ["βœ… After β€” one class per concern"]
        direction TB
        as2["AccountService\n(orchestrate)"]
        r["AccountRepository\n(persist)"]
        e["EmailService\n(notify)"]
        a["AuditService\n(log)"]
        as2 --> r & e & a
    end

O β€” Open/Closed Principle

"Software entities should be open for extension, but closed for modification." β€” Bertrand Meyer

A module is open if it can be extended with new behaviour. It is closed if its source code is stable β€” existing consumers and tests do not break when new behaviour is added. The mechanism is abstraction: define an interface that callers depend on, then add new implementations without touching the callers.

DiscountService.java β€” modified for every new type
// VIOLATION: every new customer type requires modifying this class.
// The class is open for modification but closed for extension.
public class DiscountService {

    public double calculate(Account account) {
        if ("VIP".equals(account.type())) {
            return account.orderTotal() * 0.20;
        } else if ("EMPLOYEE".equals(account.type())) {
            return account.orderTotal() * 0.30;
        } else if ("SEASONAL".equals(account.type())) {
            // Added later β€” had to reopen this class
            return account.orderTotal() * 0.15;
        } else {
            return account.orderTotal() * 0.05;
        }
        // Adding "PARTNER" requires another else-if and another class modification.
    }
}
DiscountPolicy.java β€” extension point
1
2
3
4
5
6
// Extension point: implement this interface to add a new discount rule
// without touching DiscountService.
public interface DiscountPolicy {
    boolean appliesTo(Account account);
    double calculate(Account account);
}
DiscountService.java β€” closed for modification
// Closed for modification: this class never changes when a new policy is added.
// Open for extension: inject a new DiscountPolicy implementation.
public class DiscountService {

    private final List<DiscountPolicy> policies;

    public DiscountService(List<DiscountPolicy> policies) {
        this.policies = policies;
    }

    public double calculate(Account account) {
        return policies.stream()
            .filter(p -> p.appliesTo(account))
            .findFirst()                                // same rule as before: one policy applies
            .map(p -> p.calculate(account))
            .orElse(account.orderTotal() * 0.05);       // default discount
    }
}

// Each policy is a new class β€” DiscountService is never touched again.
class VipDiscountPolicy implements DiscountPolicy {
    public boolean appliesTo(Account a) { return "VIP".equals(a.type()); }
    public double calculate(Account a)  { return a.orderTotal() * 0.20; }
}

class EmployeeDiscountPolicy implements DiscountPolicy {
    public boolean appliesTo(Account a) { return "EMPLOYEE".equals(a.type()); }
    public double calculate(Account a)  { return a.orderTotal() * 0.30; }
}
classDiagram
    class DiscountService {
        +calculate(account) double
    }
    class DiscountPolicy {
        <<interface>>
        +appliesTo(account) bool
        +calculate(account) double
    }
    class VipDiscountPolicy
    class EmployeeDiscountPolicy
    class PartnerDiscountPolicy

    DiscountService --> DiscountPolicy
    VipDiscountPolicy ..|> DiscountPolicy
    EmployeeDiscountPolicy ..|> DiscountPolicy
    PartnerDiscountPolicy ..|> DiscountPolicy

Adding PartnerDiscountPolicy requires creating one new class β€” DiscountService is never touched. In Spring, annotating each policy with @Component is enough: the container injects every DiscountPolicy bean into the List<DiscountPolicy> constructor parameter.

Closed against which changes?

No class is closed against every change. OCP asks you to anticipate the likely axis of variation (here: new customer types) and put the abstraction there. Adding extension points "just in case" produces interfaces with a single implementation forever.


L β€” Liskov Substitution Principle

"If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program." β€” the usual paraphrase of Barbara Liskov (1987) and Liskov & Wing (1994)

Every subtype must honour the contract of the supertype β€” not just the method signatures, but the behaviour. A caller that works correctly with T must work correctly with any S extends T without modification.

Rectangle.java / Square.java β€” contract broken
public class Rectangle {
    protected int width;
    protected int height;

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

// VIOLATION: Square overrides setters in a way that breaks Rectangle's contract.
// Client code that sets width and height independently will get wrong results.
public class Square extends Rectangle {

    @Override
    public void setWidth(int w) {
        this.width  = w;
        this.height = w; // forces both dimensions to be equal
    }

    @Override
    public void setHeight(int h) {
        this.width  = h; // forces both dimensions to be equal
        this.height = h;
    }
}

// Client code expecting Rectangle behaviour:
// rect.setWidth(5);   rect.setHeight(3);
// assert rect.area() == 15;   ← FAILS for Square: area() returns 9
Shape.java β€” subtypes honour the contract
// Proper design: both share a common interface without inheritance between them.
// Each type upholds its own contract independently.
public interface Shape {
    int area();
}

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 area() { return width * height; }
}

public class Square implements Shape {
    private final int side;

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

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

// Any code expecting a Shape can use Rectangle or Square interchangeably.
// No surprises β€” each type fulfils the Shape contract correctly.
classDiagram
    class Shape {
        <<interface>>
        +area() int
    }
    class Rectangle {
        -width int
        -height int
        +area() int
    }
    class Square {
        -side int
        +area() int
    }
    Rectangle ..|> Shape
    Square ..|> Shape

LSP and testing

A practical test: if substituting a subtype causes a test written for the supertype to fail, the subtype violates LSP. This is why a test suite written against an interface (Shape) must pass for every implementation.

LSP in the store: test adapters

InMemoryAccountRepository (used in unit tests) and AccountJpaAdapter (used in production) both implement AccountRepository. If the database rejects a duplicate email through a UNIQUE constraint but the in-memory version silently overwrites it, the two are not substitutable: unit tests pass and production fails. Keep the behaviour the port promises β€” not just its method signatures β€” identical in every implementation, ideally by running one shared contract test against both.


I β€” Interface Segregation Principle

"Clients should not be forced to depend on interfaces they do not use." β€” Robert C. Martin

A fat interface forces clients to declare dependencies on methods they never call. When those methods change β€” for reasons unrelated to the client β€” the client must be recompiled and re-deployed. Smaller, focused interfaces minimise coupling.

AccountRepository.java β€” fat interface
// VIOLATION: a single fat interface mixes read, write, admin, and reporting.
// Clients that only read data are forced to depend on write and admin methods.
public interface AccountRepository {

    // Read operations
    Optional<Account> findById(String id);
    Optional<Account> findByEmail(String email);
    List<Account> findAll();

    // Write operations
    Account save(Account account);
    void delete(String id);

    // Admin operations β€” not needed by most clients
    List<Account> findByRole(String role);
    void bulkImport(List<Account> accounts);

    // Reporting β€” not needed by most clients
    Map<String, Long> countByRegion();
    List<Account> findInactive(Duration since);
}

// A read-only client (e.g. a profile page service) now depends on all nine methods:
// a change to bulkImport() or countByRegion() affects it, and any test fake of this
// interface must stub write, admin and reporting methods it never calls.
AccountReader / AccountWriter / AccountAdmin
// Segregated interfaces: each client depends only on what it actually uses.

public interface AccountReader {
    Optional<Account> findById(String id);
    Optional<Account> findByEmail(String email);
    List<Account> findAll();
}

public interface AccountWriter {
    Account save(Account account);
    void delete(String id);
}

public interface AccountAdmin extends AccountReader {
    List<Account> findByRole(String role);
    void bulkImport(List<Account> accounts);
}

// AccountService depends only on what it needs β€” no unused methods.
public class AccountService {
    private final AccountReader reader;
    private final AccountWriter writer;

    public AccountService(AccountReader reader, AccountWriter writer) {
        this.reader = reader;
        this.writer = writer;
    }

    public Account findById(String id) {
        return reader.findById(id).orElseThrow(AccountNotFoundException::new);
    }

    public Account save(Account account) {
        return writer.save(account);
    }
}

// The JPA adapter implements all interfaces β€” no behaviour is lost.
class JpaAccountAdapter implements AccountReader, AccountWriter, AccountAdmin {
    // single implementation, multiple interfaces
}
classDiagram
    class AccountReader {
        <<interface>>
        +findById(id)
        +findByEmail(email)
        +findAll()
    }
    class AccountWriter {
        <<interface>>
        +save(account)
        +delete(id)
    }
    class AccountAdmin {
        <<interface>>
        +findByRole(role)
        +bulkImport(accounts)
    }
    AccountAdmin --|> AccountReader
    class JpaAccountAdapter
    JpaAccountAdapter ..|> AccountReader
    JpaAccountAdapter ..|> AccountWriter
    JpaAccountAdapter ..|> AccountAdmin

    class AccountService {
        -reader AccountReader
        -writer AccountWriter
    }
    AccountService --> AccountReader
    AccountService --> AccountWriter

AccountService depends only on AccountReader and AccountWriter. Changes to admin or reporting methods never affect it.


D β€” Dependency Inversion Principle

"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." β€” Robert C. Martin

Without DIP, high-level business logic (use cases) knows about and instantiates low-level infrastructure (databases, HTTP clients). A change to the database forces a rewrite of the business logic. DIP inverts this: the high-level module declares an interface (the abstraction), and the low-level module implements it. Both depend on the interface β€” the arrow of dependency points toward the business rule, not the infrastructure.

AccountService.java β€” tightly coupled to MySql
// VIOLATION: high-level module directly instantiates a low-level module.
// Swapping the persistence mechanism requires modifying AccountService.
public class AccountService {

    // Direct dependency on a concrete class β€” impossible to substitute in tests
    private final MySqlAccountRepository repository = new MySqlAccountRepository();

    public Account findById(String id) {
        return repository.findById(id);
    }

    public Account create(AccountIn in) {
        Account account = Account.of(in);
        repository.save(account);
        return account;
    }
}

// Concrete low-level module
class MySqlAccountRepository {
    public Account findById(String id) { /* SQL query */ return null; }
    public void save(Account account)  { /* SQL insert */ }
}
AccountService.java + AccountRepository + JpaAccountRepository
// Abstraction β€” defined by the high-level module, implemented by the low-level module.
public interface AccountRepository {
    Optional<Account> findById(String id);
    Account save(Account account);
}

// High-level module depends on the abstraction, not on a concrete class.
// The implementation is injected at runtime by the DI container (Spring).
public class AccountService {

    private final AccountRepository repository;

    public AccountService(AccountRepository repository) {
        this.repository = repository;
    }

    public Account findById(String id) {
        return repository.findById(id)
            .orElseThrow(AccountNotFoundException::new);
    }

    public Account create(AccountIn in) {
        return repository.save(Account.of(in));
    }
}

// Low-level module also depends on the abstraction.
@Repository
public class JpaAccountRepository implements AccountRepository {

    private final AccountJpaRepository jpa;

    public JpaAccountRepository(AccountJpaRepository jpa) {
        this.jpa = jpa;
    }

    @Override
    public Optional<Account> findById(String id) {
        return jpa.findById(id).map(AccountMapper::toDomain);
    }

    @Override
    public Account save(Account account) {
        return AccountMapper.toDomain(jpa.save(AccountMapper.toTable(account)));
    }
}
flowchart LR
    subgraph bad ["❌ Before"]
        as_bad["AccountService"] -->|new| mysql["MySqlAccountRepository"]
    end
    subgraph good ["βœ… After"]
        as_good["AccountService"] -->|depends on| iface["Β«interfaceΒ»\nAccountRepository"]
        jpa["JpaAccountRepository"] -->|implements| iface
        memory["InMemoryAccountRepository\n(test)"] -->|implements| iface
    end

Notice who owns the interface: AccountRepository is declared next to AccountService (the high-level module), in business terms, and the JPA class implements it. If the interface lived in the persistence package and mirrored JPA, the dependency would not really be inverted.

DIP (a design principle) is often confused with two related mechanisms:

  • Dependency Injection β€” a technique: objects receive their dependencies (usually through the constructor) instead of creating them with new. Spring's container does this for every @Service, @Repository and @Component.
  • Inversion of Control β€” the general idea that a framework calls your code (and wires it), rather than your code calling the framework.

Dependency Injection is how DIP is usually implemented in Spring, but injecting a concrete class still couples you to it; DIP requires that the injected type be an abstraction owned by the high-level module.

DIP is the principle that makes Clean Architecture and Hexagonal Architecture possible: by inverting dependencies at every layer boundary, each layer can be developed, tested, and replaced independently.


Summary

Principle Problem solved Mechanism
SRP Classes that change for multiple reasons break unrelated callers One concern per class
OCP Modifying existing code to add features breaks existing behaviour Extend via new implementations of an interface
LSP Subtypes that break supertype contracts cause subtle runtime bugs Honour behavioural contracts, not just method signatures
ISP Fat interfaces force callers to depend on unused methods Split interfaces by client need
DIP High-level logic coupled to low-level infrastructure Both depend on an abstraction; details depend on policies

SOLID is a guide, not a goal

  • Interface for every class. An interface with exactly one implementation and no test double adds a file and nothing else. Introduce the abstraction when a second implementation (or a test fake) appears.
  • SRP taken to one method per class. Splitting too far scatters one business concept across ten files; split by reason to change, not by line count.
  • Inheritance for reuse. Most LSP violations come from extends used to share code; prefer composition and small interfaces.
  • Speculative generality. Principles applied to imagined future requirements produce complexity you pay for today (YAGNI β€” You Aren't Gonna Need It).

Key takeaways

  • SOLID is about managing change: each principle limits how far a change in one place ripples through the code.
  • SRP: one reason to change (one actor) per class. OCP: add behaviour by adding classes behind an interface.
  • LSP: subtypes and implementations must keep the behaviour of the contract β€” including test doubles and adapters.
  • ISP: give each client a small interface with only what it uses. DIP: business code owns the interfaces; infrastructure implements them.
  • DIP + dependency injection is the bridge to Clean Architecture and Hexagonal Architecture, where the same ideas are applied to the whole service.


  1. MARTIN, R. C. Agile Software Development: Principles, Patterns, and Practices. Pearson, 2002. ↩

  2. MEYER, B. Object-Oriented Software Construction, 1st ed. Prentice Hall, 1988. ↩

  3. LISKOV, B.; WING, J. A Behavioral Notion of Subtyping. ACM TOPLAS, 1994. ↩

  4. MARTIN, R. C. The Single Responsibility Principle. The Clean Code Blog, 2014. ↩