Skip to content

SOLID Principles

SOLID is an acronym for five object-oriented design principles introduced by Robert C. Martin1. They were first articulated in his 2000 paper Design Principles and Design Patterns and popularised by the book Clean Code (2008). 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, 19873
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 actor. 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))
            .mapToDouble(p -> p.calculate(account))
            .sum();
    }
}

// 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.


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." — Barbara Liskov, 1987

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.


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);
}

// ReadOnlyAccountService is forced to implement (or stub) write and admin methods
// even though it never uses them — a clear ISP violation.
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

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


  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.