Library
00/08 · ~40 min
GUIDEDECK · for building software that survives

Object-Oriented
Design & the Patterns
that scale it.

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.

~40 MINMIXED TEAMLANGUAGE-AGNOSTIC
SCROLL
01 · Why this matters 3 min

Code is read and changed
far more than it's written.

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.

~70%

of a system's lifetime cost is maintenance, not initial build.

written once…

10×

…read and reasoned about ten times more.

requirements will change. Design for change, not perfection.

The two enemies

  • Coupling— how tangled your code is. When two parts are tightly coupled, changing one forces you to change the other. Edit the checkout screen and suddenly the tax calculator breaks. That's what makes code rigid and scary to touch.
  • Low cohesion — when one piece of code tries to do several unrelated jobs at once: a single class that loads data, formats a report, andemails it. That's what makes code confusing to read and hard to name.

Strip away the jargon and almost every principle ahead is really one instruction: loosen the coupling, raise the cohesion.

02 · The four pillars of OOP 7 min

An object bundles state with the
behavior that acts on it.

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.

OOPObject-Oriented Programming — is a way of organizing code around objects: small, self-contained units that keep related data together with the actions that work on it. Instead of one long script of steps, you describe the problem as a few objects that talk to each other — think a Cart that knows how to add an item and total itself. The four pillars below are the ideas that make this work.

Class vs. object

  • A class is the blueprint — it defines what data and methods every instance will have.
  • An object is one concrete instance, stamped from that blueprint with its own values.
  • One class → many independent objects.
class Car make color speed BLUEPRINT new Car · "Toyota" · red · 0 Car · "BMW" · black · 0 Car · "Tesla" · white · 0 OBJECTS · INSTANCES

One class Car blueprint stamps out many independent Car objects.

Words you'll see in the code

interface

A contract

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.

interface Notifier { send(m): void // no body }
implements

Fulfils a contract

A class promises to provide every method an interface lists, supplying the real code. One interface, many implementers.

class Email implements Notifier { send(m) { /* … */ } }
constructor

Builds an object

The special method that runs when you write new. It takes the starting values and sets up the object's initial state.

class User { constructor(name) { this.name = name } } new User("Dani")
extends

Inheritance — "is-a"

Makes a class a kind of another, inheriting its fields and methods, then adding or overriding what differs. Use sparingly (Part 3).

class Manager extends Employee { approve(r) {} }
caller (other code) Account · object PRIVATE STATE balance owner currency deposit() withdraw() balance() PUBLIC INTERFACE calls ✓ can't reach state directly ✕

An object = guarded state + a public interface. Callers use the methods; they never touch the fields.

Read the schema

  • Statelives inside, private — the object's data (balance, owner).
  • Behavior is the public interface — the only way in (deposit(), withdraw()).
  • Outside code calls methods; it can't corrupt the data because it can't reach it.
  • That single boundary is what the four pillars build on.
shape.area() ONE CALL Circle π · r² Square s · s Triangle ½ · b · h

One area() call; each shape answers in its own way.

Polymorphism — the payoff

  • Calling code depends on the Shape idea, not on any concrete shape.
  • Each type supplies its own area() — the runtime picks the right one.
  • Add a new shape and the calling loop never changes. That's the whole win.

The four pillars

Pillar 1 · Encapsulation

Hide the internals, guard the rules

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.

class Account { private balance = 0 // state — sealed off deposit(amt) { if (amt <= 0) throw Error("invalid") this.balance += amt // the rule lives in ONE place } getBalance() { return this.balance } } // acc.balance = -999 → not possible from outside
Account PRIVATE balance deposit() balance() caller calls ✓ can't reach the state directly ✕

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.

Pillar 2 · Abstraction

Expose the "what", hide the "how"

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.

interface Notifier { send(msg: string): void // the WHAT } class EmailNotifier implements Notifier { send(msg) { /* SMTP, retries, TLS… the HOW */ } } // callers know only Notifier — never SMTP details
caller Notifier send() · what EmailNotifier SMTP · how depend on the what, ignore the how

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.

Pillar 3 · Inheritance

Define a general type, then specialize

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).

abstract class Account { protected balance = 0 deposit(a) { this.balance += a } // shared } class SavingsAccount extends Account { addInterest(r) { this.balance *= 1 + r } // extra } // a SavingsAccount IS-A Account
Account deposit() SavingsAccount + addInterest() is-a

SavingsAccount is-a Account: it inherits deposit() and adds its own addInterest().

Like "a savings account is a (kind of) bank account."

Pillar 4 · Polymorphism

One call, many forms

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.

interface Shape { area(): number } class Circle implements Shape { area() {...} } class Rectangle implements Shape { area() {...} } for (const s of shapes) total += s.area() // add Triangle later — this loop never changes
shape.area() one call Circleπ r² Square Triangle½ b h

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.

03 · The inheritance trap 4 min

Favor composition over inheritance.

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.

Inheritance — rigid & leaky
class Employee { takeVacation() {...} } class Contractor extends Employee { takeVacation() { // contractors get no leave… throw Error("not allowed") // the hierarchy lied } } // every Employee caller can now blow up.
Composition — flexible & honest
class Worker { constructor(private leave: LeavePolicy) {} requestLeave() { this.leave.apply() } } new Worker(new PaidLeave()) new Worker(new NoLeave()) // policy plugged in
Inheritance — locked hierarchy
Person Employee Contractor takeVacation ✕

Contractor inherits a takeVacation()it can't honor — the chain is rigid.

Composition — plug-in behavior
Worker has-a LeavePolicy PaidLeave LeavePolicy NoLeave LeavePolicy swap policy at runtime ✓

Worker has-a LeavePolicy; swap PaidLeave ↔ NoLeave freely.

  • Inheritance says "A is aB." The relationship is baked in when you write the code and can't change while the program runs, and the child borrows the parent's actual code.
  • Composition says "A has aB." You hand one object to another as a part it uses, so you can swap that part out while the program runs — the two only meet through a shared interface.
  • Rule of thumb: reach for inheritance only when the "is-a" is genuinely true and stableand no child needs to opt out of a parent's behavior. Otherwise, compose.
04 · SOLID — the core 9 min

Five principles for classes
that bend instead of break.

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.

SOLID is an acronym for five design principles that keep classes easy to change and extend. Each letter targets one cause of fragile code; together they push you toward low coupling and high cohesion.
S
Single Responsibility
one reason to change
O
Open / Closed
extend, don't modify
L
Liskov Substitution
subtypes swap in safely
I
Interface Segregation
small, focused interfaces
D
Dependency Inversion
depend on abstractions
Rigid — tight coupling
ABCD

Every module knows every other — one change ripples everywhere.

Flexible — via abstractions
inter- face ABCD

Modules depend on shared interfaces — change one in isolation.

What SOLID buys you

  • Less coupling: edits stay local instead of rippling outward.
  • More cohesion: each class does one well-named job.
  • Easier testing & extension — new behavior plugs in.
  • That shift, applied five ways, is SOLID.
S
Single Responsibility
A class should have one reason to change.
+

Split jobs that change for different reasons. An Invoice shouldn't also render PDFs and talk to the database.

does three jobs
class Report { calculate() {...} toPDF() {...} // formatting save() {...} // persistence }
one reason each
class Report { calculate() {...} } class PdfRenderer { render(r) {...} } class ReportRepo { save(r) {...} }
O
Open / Closed
Open for extension, closed for modification.
+

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.

edit on every new type
fee(p) { switch(p.type) { case "card": return ... case "paypal": return ... } // touch this for every method }
extend by adding a class
interface Payment { fee(): number } class Card implements Payment {...} class Crypto implements Payment {...} // new method = new file, zero edits
L
Liskov Substitution
A subtype must be usable anywhere its base type is.
+

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:

  • Throws where the parent didn't (Contractor's takeVacation() throwing).
  • Demands stricter inputs than the parent accepted.
  • Returns weaker guarantees than the parent promised.
breaks the contract
// a Square "is-a" Rectangle… until you resize it class Square extends Rectangle { setWidth(w){ this.w = this.h = w } // also changes height! } resizeTo(Rectangle r){ r.setWidth(5); r.setHeight(4) assert(r.area() === 20) } // fails for Square → 16
model the real relationship
// they share a capability, not a hierarchy interface Shape { area(): number } class Square implements Shape {...} class Rectangle implements Shape {...} // no false "is-a" → nothing to break

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.

I
Interface Segregation
No client forced to depend on methods it ignores.
+

Many small, role-based interfaces beat one fat one. A Robot shouldn't implement eat() just to satisfy Worker.

fat interface
interface Worker { work(); eat(); sleep() } class Robot implements Worker { eat(){ /* …meaningless */ } }
focused roles
interface Workable { work() } interface Feedable { eat() } class Robot implements Workable {...} class Human implements Workable, Feedable {}
D
Dependency Inversion
Depend on abstractions, not concretions.
+

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.

glued to a detail
class OrderService { db = new MySqlDatabase() // hard-wired } // can't test without a real MySQL
inject the abstraction
class OrderService { constructor(private db: Database) {} } new OrderService(new Postgres()) new OrderService(new FakeDb()) // testable
05 · Design patterns 8 min

Named solutions to problems
you'll meet again and again.

Patterns are vocabulary, not law. Reach for them when the problem appears — not to show off. Six that earn their keep:

Strategy — swap an algorithm at runtime

interface SortStrategy { sort(a): Array } class Sorter { constructor(private s: SortStrategy) {} run(a) { return this.s.sort(a) } } new Sorter(new QuickSort()).run(data) // pick behavior, not branches
Sorter SortStrategy interface QuickSort MergeSort

Sorter holds a SortStrategy; swap QuickSort ↔ MergeSort without touching Sorter.

Use when
Several interchangeable algorithms (pricing, sorting, routing) and you want to choose without a switch.
It's really
Open/Closed + composition in pattern form.

Factory — centralize "which class to create"

class ParserFactory { static create(kind): Parser { if (kind === "json") return new JsonParser() if (kind === "xml") return new XmlParser() } } const p = ParserFactory.create(type) // callers decoupled from concretes
caller Factory create(kind) JsonParser XmlParser builds one

One place decides which Parser to build; callers just ask the factory.

Use when
Creation logic is non-trivial or one place should know the concrete types.
Watch for
Don't wrap a single newin a factory "just because".

Observer — broadcast change to many listeners

class Subject { subs = [] subscribe(fn) { this.subs.push(fn) } emit(e) { this.subs.forEach(fn => fn(e)) } } // the source doesn't know or care who's listening
Subject emit(e) listener listener listener notify all

The subject broadcasts to every subscriber — it never knows or cares who is listening.

Use when
One change must notify many parts (UI events, pub/sub, reactive state).
You've seen it
Every event system, RxJS, state stores, the DOM.

Adapter — make incompatible interfaces fit

// your code wants Logger; the lib speaks differently class WinstonAdapter implements Logger { constructor(private w: Winston) {} log(m) { this.w.info(m) } // translate the call }
your code wants Logger Adapter translates Winston 3rd-party log() info()

The adapter speaks your Logger interface on one side and the vendor's API on the other.

Use when
Wrapping a third-party lib or legacy API so it satisfies your interface.
Payoff
The vendor stays at the edge — swap it without touching your core.

Repository — a collection-like API over storage

interface UserRepo { findById(id): User save(u: User): void } // domain talks to UserRepo — not SQL, not an ORM. // SqlUserRepo / InMemoryUserRepo implement it.
domain UserRepo interface SqlUserRepo InMemoryRepo

Domain code depends on the UserRepo idea; a real or in-memory version plugs in behind it.

Use when
You want domain logic free of persistence details (and trivially testable).
It's really
Dependency Inversion applied to your data layer.

Dependency Injection — hand dependencies in, don't build them

// not a GoF pattern, but the most important habit: class Checkout { constructor( private repo: OrderRepo, private pay: Payment, private notify: Notifier, ) {} // collaborators injected → swappable + testable }
main() builds deps Postgres OrderService receives Database inject

main() builds the Database and hands it in — OrderService never creates its own.

Use when
Always. It's the practical mechanics of the "D" in SOLID.
Bonus
A DI container can wire these, but constructor injection alone gets 90% of the value.
06 · Architectural patterns 7 min

The same rule, zoomed out:
keep dependencies pointing inward.

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.

Start here

Layered (N-tier)

Presentation / UI Application Domain / Business Infrastructure / Data

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).

Decouple the edges

Hexagonal · Ports & Adapters

Domain + ports UI CLI / API Database Ext. API

The core defines ports (interfaces); adapters implement them. Drive it from a test, a CLI, or HTTP — the domain never knows which.

The strict form

Clean / Onion

Entities Use cases Adapters Frameworks deps point in

The Dependency Rule: source dependencies only point inward. Entities know nothing of DB or web. Maximum testability — at the cost of more layers.

And for the UI: MVC → MVVM → MVI

  • MVC — Model / View / Controller. The Controller sits in the middle, taking user input and updating the Model and View. The classic shape for server-rendered web pages.
  • MVVM— a ViewModel holds the screen's state, and the View updates itself automatically whenever that state changes. Fits UI toolkits that re-render on their own when data updates.
  • MVI — data flows in one direction only: 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.

  • Small CRUD / prototype → Layered. Don't over-engineer.
  • Rich domain, many integrations, heavy testing → Hexagonal / Clean.
  • Match the UI pattern to your framework's grain (MVVM with declarative UI, MVI for strict state).
  • Migrate toward stricter only when coupling pain justifies it — YAGNI.
07 · Principles that tie it together 2 min

The everyday discipline
behind every pattern.

DRY

Don't Repeat Yourself

Every piece of knowledge has one home. But beware: duplicate code ≠ duplicate knowledge— don't couple two things just because they look alike today.

KISS

Keep It Simple

The simplest design that meets the requirement wins. Clever is a liability when someone else debugs it at 2 a.m.

YAGNI

You Aren't Gonna Need It

Don't build for imagined futures. Add abstraction when the second real case arrives — not before.

SoC

Separation of Concerns

One module, one concern. The macro version of Single Responsibility — why layers and ports exist.

Testability

Design for testing

If it's hard to test, it's badly coupled. Test pain is a design smell, not a testing problem. DI makes it easy.

Tension

Balance, don't dogma

DRY vs KISS, abstraction vs YAGNI — these pull against each other. Judgment is knowing which to favor here.

08 · Recap & takeaways 1 min

Five rules to walk out with.

1Optimize for change. Low coupling, high cohesion is the whole game.
2Program to interfaces. Encapsulation + polymorphism let one line survive new requirements.
3Compose, then inherit.Use inheritance only for a true, stable "is-a".
4Point dependencies inward. SOLID at the class level, Clean/Hexagonal at the system level — same idea.
5Apply, don't worship.Patterns and principles are tools. KISS & YAGNI keep the others honest.

Keep going

  • Clean Code & Clean Architecture — Robert C. Martin
  • Design Patterns— the "Gang of Four" (reference, not a reading list)
  • Refactoring — Martin Fowler
  • refactoring.guru — patterns explained visually, free

One sentence to remember

"Make the change easy, then make the easy change."

— Kent Beck

Knowledge check

Did it stick?

Five quick questions on OOP, SOLID, composition, patterns, and architecture — instant feedback, no sign-in.

Rate this deck
be the first

Navigate with ← → or scroll · back to library