SOLID Principles
The five object-oriented design principles that keep code maintainable — each one a different way to isolate change. Spot the violation, then see the refactor.
SOLID is a set of five principles (coined by Robert C. Martin) for writing object-oriented code that's easy to change, test, and extend. They aren't rules to apply mechanically — they're heuristics that all push toward the same goal: high cohesion and low coupling, so a change in one place doesn't cascade into many.
A class should have one reason to change. Bundling data, formatting, persistence, and delivery into one class means a change to any of them risks all of them. Split responsibilities so each unit has a single, well-defined job.
Software entities should be open for extension but closed for modification. Instead of editing a growing if/else (or switch) every time a new case appears, depend on an abstraction so new behavior is added as a new class — leaving tested code untouched.
A subtype must be substitutable for its base type without breaking callers. If overriding a method violates the base class's contract (the classic Square/Rectangle trap), inheritance is the wrong tool — model a shared abstraction instead.
Interface Segregation: prefer many small, role-specific interfaces over one fat one, so clients don't depend on methods they ignore. Dependency Inversion: high-level modules and low-level details should both depend on abstractions, with the concrete detail injected — the foundation of testable, swappable code.
Key Concepts
One class, one reason to change. Isolate unrelated jobs so a change to one can't break another.
Extend behavior by adding new code (new classes), not by modifying existing, tested code.
Subtypes must honor the base type's contract so they're safely interchangeable. No surprising overrides.
Many focused interfaces beat one fat interface; clients depend only on what they actually use.
Depend on abstractions, not concretions, and inject the concrete detail. Enables testing and swapping.
SOLID raises cohesion (related things together) and lowers coupling (fewer rigid dependencies) — the real goal.
1// The Dependency Inversion Principle in one picture.23// ❌ High-level policy hard-wired to a low-level detail4class OrderService {5 private db = new MySqlDatabase(); // concrete dependency6 place(order) { this.db.insert(order); } // untestable, unswappable7}89// ✅ Both depend on an abstraction; the detail is injected10interface OrderStore { save(o: Order): Promise<void>; }1112class OrderService {13 constructor(private store: OrderStore) {} // inversion of control14 place(order) { return this.store.save(order); }15}1617class SqlOrderStore implements OrderStore { /* prod */ }18class InMemoryOrderStore implements OrderStore { /* tests */ }1920// Wiring happens at the composition root (DI container):21const service = new OrderService(new SqlOrderStore());
SOLID is the shared vocabulary of professional OO design and a near-guaranteed interview topic. More practically, codebases that respect these principles are dramatically cheaper to change: new features slot in as new classes, dependencies are swappable, and unit tests are easy because seams exist where they need to.
Common Pitfalls
1The Payment Switch That Grew Out of Control
A checkout has a processPayment() method with a switch over 'stripe', 'paypal', 'applepay'. Adding a new provider means editing this method, re-testing every branch, and risking a regression in unrelated providers.
The method violates Open/Closed: every new payment type modifies existing, tested code. The switch also tends to accumulate provider-specific quirks, turning into a fragile God method.
Define a PaymentProvider interface with a single charge() method. Each provider becomes its own class implementing it, selected via a factory. Adding a provider is now a new class plus one registration line — the checkout code never changes.
Takeaway: When you find yourself editing the same switch/if-else for every new case, apply Open/Closed: invert to an interface and add behavior as new types. Existing code stays closed and safe.
2Untestable Service Pinned to a Real Database
A team can't unit-test their OrderService because it news up a real database connection inside the constructor. Tests are slow, flaky, and require a live DB.
OrderService violates Dependency Inversion — it depends on a concrete MySqlDatabase. There's no seam to substitute a fake, so 'unit' tests are really integration tests against infrastructure.
Introduce an OrderStore interface, inject it via the constructor, and wire the concrete SqlOrderStore at the composition root (DI container). Tests pass an InMemoryOrderStore — fast, deterministic, no infrastructure.
Takeaway: Dependency Inversion isn't academic — it's what makes code testable. Inject abstractions so you can substitute fakes, and let a DI container assemble the real implementations in production.
Join the discussion
Comments
Loading comments...
Cookie preferences
We use essential cookies for sign-in and preferences. With your permission, we also use basic analytics to improve lessons.