A 40-minute working session on writing code that's easy to change — from the four pillars of OOP, through SOLID, the design patterns worth knowing, and the architectures they grow into.
Good design isn't about looking clever. It comes down to one very practical question: when the next person — often future-you — needs to change this code, how easily can they do it without breaking something else? Everything ahead is a tool for making that change cheaper and safer.
of a system's lifetime cost is maintenance, not initial build.
written once…
…read and reasoned about ten times more.
requirements will change. Design for change, not perfection.
Strip away the jargon and almost every principle ahead is really one instruction: loosen the coupling, raise the cohesion.
A class is the blueprint; an object is one thing built from that blueprint. The whole idea is to keep a piece of data and the rules that protect it together in one place, so the rest of your code asks the object to do things instead of poking at its raw values. The four pillars below are how you do that well.
Cart that knows how to add an item and total itself. The four pillars below are the ideas that make this work.One class Car blueprint stamps out many independent Car objects.
A named list of methods a thing must have — just their names and shapes (the signature), with no actual code inside. Other code relies on the contract, so any class that fulfils it can be dropped in.
A class promises to provide every method an interface lists, supplying the real code. One interface, many implementers.
The special method that runs when you write new. It takes the starting values and sets up the object's initial state.
Makes a class a kind of another, inheriting its fields and methods, then adding or overriding what differs. Use sparingly (Part 3).
An object = guarded state + a public interface. Callers use the methods; they never touch the fields.
balance, owner).deposit(), withdraw()).One area() call; each shape answers in its own way.
area() — the runtime picks the right one.Bundle data with the methods that protect it, and keep that data private. The object becomes the onlyplace its rules can be enforced — so they can't be broken from outside.
Callers go through the public methods; the private state can't be touched from outside.
Like an ATM — you press buttons; you can't reach into the cash drawer.
Give callers a simple, stable idea to depend on — send(message) — and hide the messy mechanism behind it. Swap the implementation and nothing upstream changes.
Callers know only the Notifier interface (the what); the SMTP details (the how) stay hidden.
Like a steering wheel — you turn it; you ignore the rack-and-pinion.
A subclass is a kind of its parent and reuses its behavior, adding or overriding what differs. Powerful — but the most over-used pillar (see Part 3: prefer composition).
SavingsAccount is-a Account: it inherits deposit() and adds its own addInterest().
Like "a savings account is a (kind of) bank account."
Write code against the general type; let each subtype answer in its own way. The dispatch happens at runtime — so you can add new types without touching the calling code.
One area() call; the runtime picks each shape's own version — add a shape, the caller never changes.
Like a "play" button — works on a song, a video, a podcast.
When a class inherits from a parent, it's tied to that parent's inner workings for good — change the parent and every child feels it. Stack several levels deep and the whole tree turns stiff: one tweak at the top ripples down everywhere, and the tidy "a Contractor is anEmployee" story tends to fall apart as real-world requirements pile up.
Contractor inherits a takeVacation()it can't honor — the chain is rigid.
Worker has-a LeavePolicy; swap PaidLeave ↔ NoLeave freely.
Click each letter to expand a before → after example. If your team takes only one slide from this whole session to heart, make it this one.
Every module knows every other — one change ripples everywhere.
Modules depend on shared interfaces — change one in isolation.
Split jobs that change for different reasons. An Invoice shouldn't also render PDFs and talk to the database.
Add new behavior by writing new code, not by editing code that already works and is tested. The warning sign is a switch or if/else that grows a new branch every time a new case shows up; letting each type bring its own version of the method (polymorphism) is the fix.
In plain words: if your code works with a base type, it must keep working when handed any subclass — no surprises. Hand a function an Employee or a Contractor and it should behave correctly either way.
Formally: if S is a subtype of T, you can substitute an S wherever a T is expected and the program stays correct. A subclass breaks Liskov when it does any of these:
takeVacation() throwing).Smell test: if you override a method just to disable it (throw "not supported"), the "is-a" is a lie — use composition or a narrower interface instead.
Many small, role-based interfaces beat one fat one. A Robot shouldn't implement eat() just to satisfy Worker.
Your important business logic shouldn't reach down and grab a specific tool, like a particular database. Instead, both the logic and the tool agree on an interface in the middle — so you can swap MySQL for Postgres, or plug in a fake database when running tests, without touching the logic.
Patterns are vocabulary, not law. Reach for them when the problem appears — not to show off. Six that earn their keep:
Sorter holds a SortStrategy; swap QuickSort ↔ MergeSort without touching Sorter.
One place decides which Parser to build; callers just ask the factory.
newin a factory "just because".The subject broadcasts to every subscriber — it never knows or cares who is listening.
The adapter speaks your Logger interface on one side and the vendor's API on the other.
Domain code depends on the UserRepo idea; a real or in-memory version plugs in behind it.
main() builds the Database and hands it in — OrderService never creates its own.
SOLID is about individual classes; architecture is the same instinct applied to a whole system. The three styles below are really one idea getting progressively stricter: keep your core business rules in the middle, and push the parts that change often — the UI, the database, the framework — out to the edges.
Each layer talks only to the one directly below it. Simple and familiar — though your business rules end up leaning on the database. A solid default for straightforward apps that mostly create, read, update, and delete records (often called CRUD).
The core defines ports (interfaces); adapters implement them. Drive it from a test, a CLI, or HTTP — the domain never knows which.
The Dependency Rule: source dependencies only point inward. Entities know nothing of DB or web. Maximum testability — at the cost of more layers.
intent → state → view, with a single state object that is never edited in place, only replaced. Predictable and easy to debug because every screen comes from one known state.All three answer the same question: keep view logic out of business logic.
Every piece of knowledge has one home. But beware: duplicate code ≠ duplicate knowledge— don't couple two things just because they look alike today.
The simplest design that meets the requirement wins. Clever is a liability when someone else debugs it at 2 a.m.
Don't build for imagined futures. Add abstraction when the second real case arrives — not before.
One module, one concern. The macro version of Single Responsibility — why layers and ports exist.
If it's hard to test, it's badly coupled. Test pain is a design smell, not a testing problem. DI makes it easy.
DRY vs KISS, abstraction vs YAGNI — these pull against each other. Judgment is knowing which to favor here.
"Make the change easy, then make the easy change."
— Kent Beck
Five quick questions on OOP, SOLID, composition, patterns, and architecture — instant feedback, no sign-in.
Navigate with ← → or scroll · back to library