SOLID Principles Cheat Sheet
Explains the five SOLID object-oriented design principles with concrete before-and-after code examples for each one.
The Five Principles
A one-line summary of each SOLID principle.
- S - Single Responsibility- A class should have only one reason to change, i.e. one responsibility
- O - Open/Closed- Software entities should be open for extension but closed for modification
- L - Liskov Substitution- Subtypes must be substitutable for their base types without altering program correctness
- I - Interface Segregation- Clients shouldn't be forced to depend on methods or interfaces they don't use
- D - Dependency Inversion- High-level modules should depend on abstractions, not concrete low-level implementations
Single Responsibility
Splitting a class that has more than one reason to change.
// Violates SRP: handles both persistence and formattingclass Report { void generate() { /* build report data */ } void saveToFile(String path) { /* file I/O */ } void printToConsole() { /* formatting + printing */ }}// Follows SRP: each class has one reason to changeclass Report { void generate() { /* build report data */ }}class ReportSaver { void saveToFile(Report report, String path) { /* file I/O */ }}class ReportPrinter { void printToConsole(Report report) { /* formatting */ }}
Open/Closed & Dependency Inversion
Extending behavior through an abstraction instead of editing existing code.
interface PaymentMethod { void pay(double amount);}class CreditCardPayment implements PaymentMethod { public void pay(double amount) { /* charge card */ }}class PayPalPayment implements PaymentMethod { public void pay(double amount) { /* charge PayPal */ }}// Adding a new payment method requires no changes to Checkoutclass Checkout { private final PaymentMethod paymentMethod; Checkout(PaymentMethod paymentMethod) { this.paymentMethod = paymentMethod; } void completeOrder(double total) { paymentMethod.pay(total); }}
Liskov Substitution & Interface Segregation
A subtype that breaks its parent's contract, and a fat interface split apart.
// Violates LSP: Square changes Rectangle's expected behaviorclass Rectangle { protected int width, height; void setWidth(int w) { width = w; } void setHeight(int h) { height = h; } int area() { return width * height; }}class Square extends Rectangle { void setWidth(int w) { width = height = w; } // breaks Rectangle's contract void setHeight(int h) { width = height = h; }}// Follows ISP: split a fat interface into focused onesinterface Printer { void print(Document d); }interface Scanner { void scan(Document d); }// SimplePrinter only implements Printer, not forced to implement scan()class SimplePrinter implements Printer { public void print(Document d) { /* ... */ }}
Dependency Inversion with a Composition Root
Wiring concrete implementations to abstractions in one place instead of letting high-level modules construct their own dependencies.
interface Logger { log(msg: string): void;}class ConsoleLogger implements Logger { log(msg: string) { console.log(msg); }}class OrderService { // depends on the abstraction, injected, not `new ConsoleLogger()` inline constructor(private logger: Logger) {} placeOrder(id: string) { this.logger.log(`order ${id} placed`); }}// Composition root -- the only place that knows concrete typesconst logger: Logger = new ConsoleLogger();const service = new OrderService(logger);// Swapping to a FileLogger or NullLogger (for tests) touches only// this one wiring point, never OrderService itself.
Interface Segregation with Role Interfaces
Splitting a fat repository interface into small, role-specific interfaces so consumers depend only on what they call.
// Fat interface -- every implementer must support everythinginterface UserRepository { findById(id: string): User; save(u: User): void; delete(id: string): void; bulkImport(rows: User[]): void; auditLog(): AuditEntry[];}// Segregated role interfacesinterface UserReader { findById(id: string): User;}interface UserWriter { save(u: User): void; delete(id: string): void;}// A read-only reporting service depends only on UserReader --// a mock in tests implements one method, not five.class ReportingService { constructor(private users: UserReader) {}}
Spotting SRP Violations via Method-Field Cohesion
A concrete technique for detecting classes with more than one reason to change before splitting them.
# Smell: methods cluster into two disjoint groups that never touch# the same fields -- a sign the class has two responsibilities.class InvoicePrinter: def __init__(self, items, tax_rate): self.items = items self.tax_rate = tax_rate self._connection = None # only used by save_* methods def subtotal(self): # touches: items return sum(i.price for i in self.items) def total(self): # touches: items, tax_rate return self.subtotal() * (1 + self.tax_rate) def save_to_db(self): # touches: _connection only self._connection.execute("INSERT ...") def save_to_s3(self): # touches: _connection only self._connection.upload(...)# subtotal/total form one cluster (pricing logic); save_to_db/save_to_s3# form another (persistence) -- split into InvoiceCalculator and# InvoiceRepository.
LSP Beyond Types: Pre/Postcondition Contracts
Liskov substitution is violated by strengthened preconditions or weakened postconditions, not just type signatures.
class FileValidator { // precondition: path must be non-null boolean isValid(String path) { return path != null && path.endsWith(".txt"); }}// Violates LSP: strengthens the precondition (now also requires// the file to exist on disk), which callers of the base type// don't expect and won't check for.class StrictFileValidator extends FileValidator { boolean isValid(String path) { return super.isValid(path) && new File(path).exists(); }}// A caller written against FileValidator that passes a path for a// not-yet-created file works with the base class but throws// unexpected behavior downstream when swapped to StrictFileValidator --// LSP requires subtypes to only weaken preconditions or strengthen// postconditions, never the reverse.
Code Smells That Map to SOLID Violations
Recognizable symptoms and which principle they typically point back to.
- God class / God object- Usually a Single Responsibility violation: too many unrelated reasons to change live in one class
- Long chain of if/else on type codes- Often an Open/Closed violation -- replace with polymorphism so new types don't require editing existing branches
- instanceof / type-checking before calling a method- A common Liskov Substitution smell -- the caller shouldn't need to know the concrete subtype
- Interface with unused method stubs (throw NotImplementedError)- Classic Interface Segregation violation -- the interface is forcing capabilities the implementer doesn't have
- `new ConcreteClass()` sprinkled through business logic- Dependency Inversion violation -- high-level code is bound to low-level construction details
- Shotgun surgery- A single conceptual change requires edits across many classes, often from a missing abstraction (DIP) or split responsibility (SRP)
Treat SOLID as heuristics, not laws — over-applying Dependency Inversion or Interface Segregation to simple, stable code adds indirection without payoff. Apply them where change is actually expected.