SOLID
Five principles that keep classes easy to change, shown on one growing parking lot.
What you will learn
- 01Why principles at all
- 02S: Single responsibility
- 03O: Open for extension, closed for modification
- 04L: Liskov substitution
- 05I: Interface segregation
- 06D: Dependency inversion
- 07Using SOLID with judgment
Why principles at all
Code is read and changed far more often than it is written. SOLID is a set of five guidelines that make change cheap by keeping each class focused, extensible, substitutable, narrow and loosely wired. They are guides, not laws. We will watch one design improve as we apply each.
Swipe sideways to pan · drag a box to move it
S: Single responsibility
A class should have one reason to change. Here is a class with three.
class ParkingLot {
fee(t: Ticket, now: number) { /* pricing rules */ }
print(t: Ticket) { /* receipt layout */ }
save(t: Ticket) { /* database calls */ }
}
A pricing change, a new receipt format and a new database all force edits to the same file, and each can break the others. Split by reason to change: a PricingPolicy, a ReceiptPrinter, a TicketRepository, and let ParkingLot coordinate them.
O: Open for extension, closed for modification
You should add behavior by adding code, not by editing tested code. Replace the if (kind === ...) chain with an abstraction.
interface PricingRule { fee(minutes: number): number }
class CarRule implements PricingRule { fee(m: number) { return Math.ceil(m / 60) * 20 } }
class BikeRule implements PricingRule { fee(m: number) { return Math.ceil(m / 60) * 10 } }
A scooter rule is a new class. Nothing existing changes, so nothing existing can break.
Swipe sideways to pan · drag a box to move it
L: Liskov substitution
Anywhere the base type is used, any subtype must work without surprises. A subtype must honor the promises of its parent: accept at least what the parent accepts, and deliver at least what the parent delivers.
The classic violation is a ReadOnlyLot that extends ParkingLot but throws when enter is called. Code written against ParkingLot now crashes. If a subtype must weaken a promise, it should not be a subtype. Model the difference with separate interfaces instead.
I: Interface segregation
No client should depend on methods it does not use. One fat interface forces every implementer to supply everything and every caller to know about everything.
interface Reader { find(id: string): Ticket | undefined }
interface Writer { save(t: Ticket): void }
A reporting screen needs only Reader. A test double for it has one method to fake, not ten.
D: Dependency inversion
High level policy should not depend on low level detail. Both should depend on an abstraction. Instead of creating its own database, the lot receives something that satisfies an interface.
class ParkingLot {
constructor(private rules: PricingRule, private store: Writer & Reader) {}
}
Now the lot can be tested with an in-memory store and run in production with a real one, with no change to its code. Handing dependencies in from outside is called dependency injection.
Swipe sideways to pan · drag a box to move it
Using SOLID with judgment
Every principle adds types and indirection. Apply them where you feel change happening, not on day one for everything. A good test: when requirements change, how many files must you edit? If the answer is usually one, the design is working. If it is usually five, look for the principle that was skipped.