Five principles that keep object-oriented code flexible and maintainable. SOLID is the shared vocabulary every architect uses to spot and fix rigid, fragile designs.
Why: SRP says a class should have one reason to change (one responsibility), so unrelated concerns do not tangle together; OCP says code should be open to extension but closed to modification, so you add behavior without editing tested code. When: split a class when it has two jobs; add a new type instead of a new if-branch. Where: together these two keep changes localized and additive.
// SRP: this class does ONE thing (invoice math). Formatting + emailing live
// elsewhere — each has its own reason to change.
// OCP: add a new shape by ADDING a class, not EDITING this function.
interface Shape { area(): number }
class Circle implements Shape { constructor(public r: number) {} area() { return Math.PI * this.r ** 2 } }
class Square implements Shape { constructor(public s: number) {} area() { return this.s ** 2 } }
function totalArea(shapes: Shape[]) {
return shapes.reduce((sum, s) => sum + s.area(), 0)
// Adding a Triangle needs a new class — this function never changes.
}Why: LSP says a subtype must be usable anywhere its base type is expected without surprises (no strengthened preconditions or broken behavior); ISP says clients should not be forced to depend on methods they do not use, so keep interfaces small and focused. When: if a subclass throws "not supported" for a base method, LSP is violated; if implementers stub out unused methods, split the interface. Where: both prevent leaky abstractions that break callers.
// LSP violation: a Penguin "is a" Bird but can't fly — substituting it breaks
// code that calls bird.fly(). Fix: don't put fly() on Bird; model it separately.
// ISP: don't force implementers to stub methods they don't need.
// BAD: interface Worker { work(); eat() } // a Robot must fake eat()
interface Workable { work(): void }
interface Eatable { eat(): void }
class Robot implements Workable { work() {} } // only what it needs
class Human implements Workable, Eatable { work() {} eat() {} }Why: DIP says high-level policy should not depend on low-level details — both should depend on abstractions — so your business logic does not hard-wire a specific database, queue, or API, and can be tested and swapped freely. When: depend on an interface and inject the concrete implementation. Where: this is the principle behind dependency injection and clean/hexagonal architecture, and it is the most impactful SOLID rule at the architecture level.
// BAD: high-level OrderService is welded to a concrete database.
// class OrderService { db = new PostgresDb() } // can't test or swap
// GOOD: depend on an abstraction; inject the detail.
interface OrderRepository { save(order: Order): void }
class OrderService {
constructor(private repo: OrderRepository) {} // depends on the INTERFACE
place(order: Order) { this.repo.save(order) }
}
// Inject PostgresRepo in prod, an in-memory fake in tests. Policy is decoupled
// from detail — the core idea of clean/hexagonal architecture.