Concepts you should know about system designing (part 2)
If Part 1 was the architecture vocabulary, this is the code vocabulary - and unlike architecture, low-level design is graded on whether you can actually write it. A machine-coding round gives you 60 to 120 minutes to turn a problem into clean, extensible classes, then defends or shreds your design in review. This post explains the low-level design concepts in depth: the object-oriented foundations, the design principles (DRY, KISS, SOLID) with before-and-after code, and the design patterns with real, minimal implementations you can reason about. Examples are in TypeScript, because its explicit interfaces and types put the design on the page; the ideas are language-agnostic.
This is Part 2 of the two-part concepts reference in the System Design Guide series. Part 1 covered the high-level design vocabulary (CAP, caching, sharding); this part is the LLD side. Writing idiomatic, well-structured code is the through-line - the same instinct as the idiomatic Golang, applied to design.
The four pillars, and what they actually buy you
Object orientation rests on four ideas, but the point of each is a concrete engineering benefit, not a definition to recite.
Encapsulation bundles data with the methods that operate on it and hides the internals behind an interface, so callers depend on what an object does, not how. The benefit is that you can change the implementation without breaking callers. A BankAccount that exposes deposit() and withdraw() but keeps its balance private can later add overdraft rules, logging, or a different storage backend, and no caller notices - because no caller was ever allowed to poke at the balance directly.
Abstraction exposes only essential behavior and suppresses detail. An interface (or abstract base class) is abstraction made concrete: PaymentMethod with a single pay(amount) method lets the rest of the system deal in "a payment method" without knowing or caring whether it is a card, a wallet, or UPI.
Inheritance lets a subclass reuse and extend a parent (an is-a relationship: a Car is a Vehicle). It is powerful and heavily overused - deep inheritance trees are rigid and fragile, which is why the composition principle below exists as a counterweight.
Polymorphism means one interface backed by many implementations, where the same call does the right thing for whatever concrete type is behind it. This is the mechanism that makes half the design patterns work:
interface PaymentMethod { pay(amount: number): void; }
class Card implements PaymentMethod {
pay(amount: number) { console.log(`Charging card ${amount}`); }
}
class Wallet implements PaymentMethod {
pay(amount: number) { console.log(`Deducting wallet ${amount}`); }
}
function checkout(method: PaymentMethod, amount: number) {
method.pay(amount); // same call, different behavior per type
}
checkout never changes when you add a new payment method - that is polymorphism paying off, and it is the seed of the Strategy pattern.
Coupling and cohesion: the two words the rest of this post serves
Almost every principle below is a tactic for one goal: low coupling and high cohesion.
Coupling is how much one class depends on the internals of another. High coupling means a change ripples - edit one class and three others break. Low coupling means classes interact through small, stable interfaces, so changes stay local. Cohesion is how focused a single class is: a highly cohesive class does one well-defined job and everything in it serves that job; a low-cohesion class is a junk drawer of unrelated methods. You want low coupling between classes and high cohesion within each. When you evaluate any design - yours or the one an interviewer sketches - these are the two lenses.
The general principles, with the smell they fix
DRY - Don't Repeat Yourself. Every piece of knowledge should have exactly one authoritative representation. The reason is not aesthetics - it is that duplicated logic drifts. Fix a bug in one copy, miss the other three, and now they disagree. When the same tax calculation appears in the cart, the invoice, and the report, a rate change silently breaks two of them. Extract it once:
// smell: the same rule copy-pasted, guaranteed to drift
const cartTotal = (items: Item[]) => items.reduce((s, i) => s + i.price, 0) * 1.18;
const invoiceTotal = (items: Item[]) => items.reduce((s, i) => s + i.price, 0) * 1.18;
// DRY: one source of truth
const TAX_RATE = 0.18;
const withTax = (subtotal: number): number => subtotal * (1 + TAX_RATE);
A caution: DRY is about knowledge, not about text that merely looks similar. Two functions that happen to have the same three lines but represent different rules should stay separate - deduplicating them couples two things that change for different reasons.
KISS - Keep It Simple. The simplest design that solves the actual problem wins. Clever, dense, over-abstracted code is a cost you pay on every future read. In a timed round especially, a clear five-class solution that works beats an elaborate pattern-stuffed one that does not compile.
YAGNI - You Aren't Gonna Need It. Do not build for imagined future requirements. Speculative generality - the plugin system nobody asked for, the config for a case that will never occur - is complexity you pay for now and almost never use. Solve the problem in front of you; if the future arrives, refactor then. KISS and YAGNI together are the antidote to the most common LLD failure, over-engineering.
Separation of concerns. Keep distinct responsibilities in distinct places. Business logic, persistence, and presentation should not be tangled in one class - a class that computes an order total and formats HTML and writes to the database is three concerns fused, and a change to any one risks the others.
Law of Demeter (least knowledge). A method should talk only to its immediate collaborators, never reach through them. order.getCustomer().getWallet().deduct(amount) is the smell - it couples the caller to the entire object graph, so a change to Wallet or Customer breaks code that just wanted to charge an order. Instead, expose order.charge(amount) and let Order talk to its own Customer. Talk to friends, not to friends of friends.
Composition over inheritance. Prefer assembling behavior from smaller objects (has-a) over deep inheritance trees (is-a). Inheritance binds a subclass to its parent's implementation forever and forces every variation into a rigid hierarchy; composition lets you mix and match behaviors and swap them at runtime.
// inheritance explosion: FlyingSwimmingRobotDuck, and every combination...
// composition: give a Duck behaviors it holds and can swap
class Duck {
constructor(private fly: FlyBehavior, private quack: QuackBehavior) {}
}
When you catch yourself designing a tall class tree, stop and compose instead - it is almost always the more flexible answer, and it is the structural idea behind the Strategy and Decorator patterns.
SOLID, each with before and after
The five principles that keep object-oriented code open to change. Here is the compressed version with one refactor each; for the full treatment - every principle with violations, fixes, and the nuances people get wrong - read the dedicated SOLID deep-dive in this series.
S - Single Responsibility Principle. A class should have one reason to change - one job. A Report that generates content, renders PDF, and writes files has three reasons to change and any one edit risks the others.
// before: three responsibilities in one class
class Report {
content() {}
toPdf() {}
save(path: string) {}
}
// after: one reason to change each
class Report { content() {} }
class PdfRenderer { render(report: Report) {} }
class FileStore { save(data: string, path: string) {} }
O - Open/Closed Principle. Open for extension, closed for modification - add behavior by adding code, not by editing tested code. The tell is a growing if/else on a type:
// before: every new shape edits this function
function area(shape: any): number {
if (shape.kind === "circle") return 3.14 * shape.r ** 2;
if (shape.kind === "square") return shape.s ** 2;
return 0;
}
// after: new shapes are new classes; area() never changes
interface Shape { area(): number; }
class Circle implements Shape {
constructor(private r: number) {}
area() { return 3.14 * this.r ** 2; }
}
class Square implements Shape {
constructor(private s: number) {}
area() { return this.s ** 2; }
}
L - Liskov Substitution Principle. A subclass must be usable anywhere its parent is, without breaking expectations. The classic violation is Square inheriting from Rectangle: setting width and height independently is valid for a rectangle but nonsensical for a square, so code that works on Rectangle breaks. If a subclass has to weaken or contradict the parent's contract, the inheritance is wrong - prefer composition.
I - Interface Segregation Principle. Prefer many small, focused interfaces over one fat one; never force a class to implement methods it does not use. A single Machine interface with print, scan, and fax forces a simple printer to stub out scan and fax. Split it into Printer, Scanner, and Fax.
D - Dependency Inversion Principle. High-level policy should depend on abstractions, not concrete low-level classes. The NotificationService should depend on a Notifier interface, not a concrete EmailSender - so you can inject email, SMS, or a test double without touching the service:
interface Notifier { send(msg: string): void; }
class EmailNotifier implements Notifier {
send(msg: string) { console.log(`email: ${msg}`); }
}
class NotificationService {
constructor(private notifier: Notifier) {} // depends on the abstraction
notify(msg: string) { this.notifier.send(msg); }
}
DIP is the one interviewers probe hardest, because it is what makes code testable and swappable. When a design feels rigid, an injected abstraction is usually the fix.
Modeling relationships with UML
In an LLD round you will sketch a class diagram, and the relationships have precise meanings:
- Association - one class uses another (a
Driveruses aCar). Drawn as a plain line. - Aggregation - a
has-awhere the part outlives the whole (aTeamhasPlayers; delete the team and the players still exist). Hollow diamond on the container. - Composition - a stronger
has-awhere the part dies with the whole (aHousehasRooms; no house, no rooms). Filled diamond. - Inheritance - an
is-a(aCaris aVehicle). Hollow triangle arrow to the parent. - Dependency - a transient use, like taking another class as a method parameter. Dashed arrow.
Getting aggregation vs composition right signals you actually think about object lifetimes, which reviewers notice.
Design patterns, with real implementations
Design patterns are named, proven solutions to recurring design problems. The Gang of Four catalog has 23, grouped into three families. You do not memorize all of them; you learn to recognize which problem you are looking at. Below are the ones that actually appear in interviews, with minimal code.
Creational - how objects get made
Factory centralizes object creation so callers ask for what they want, not how to build it, and adding a new type does not touch the callers (Open/Closed in action):
function makeNotifier(kind: string): Notifier {
const registry: Record<string, () => Notifier> = {
email: () => new EmailNotifier(),
sms: () => new SmsNotifier(),
};
return registry[kind]();
}
Builder constructs a complex object step by step, avoiding a constructor with ten positional arguments - Pizza.builder().size("L").add("cheese").add("olives").build(). Reach for it when an object has many optional parts.
Singleton guarantees exactly one shared instance (a config object, a connection pool). Use it sparingly: it is a global variable in a nicer coat, and it makes testing harder because the shared state leaks between tests.
Prototype creates new objects by cloning an existing configured one, useful when construction is expensive and you want copies of a template.
Structural - how objects are composed
Decorator adds behavior to an object at runtime by wrapping it in another object with the same interface - the reason you can stack "add logging" then "add caching" without subclassing every combination:
interface Coffee { cost(): number; }
class BasicCoffee implements Coffee { cost() { return 100; } }
class WithMilk implements Coffee { // same interface as Coffee
constructor(private inner: Coffee) {}
cost() { return this.inner.cost() + 20; }
}
const latte = new WithMilk(new WithMilk(new BasicCoffee())); // wrap freely
Adapter wraps an incompatible interface so it fits the one your code expects - the standard way to isolate a third-party library behind your own interface so you can swap vendors later (and it is DIP made practical). Facade puts a simple front over a complex subsystem. Composite lets you treat a single object and a tree of objects uniformly (files and folders both answer size()). Proxy and Flyweight round out the family.
Behavioral - how objects interact
Strategy is the single most useful LLD pattern: encapsulate a family of interchangeable algorithms behind a common interface and select one at runtime. Any problem that says "the pricing can vary," "different allocation policies," "pluggable ranking" is a Strategy:
interface PricingStrategy { price(base: number): number; }
class Regular implements PricingStrategy { price(base: number) { return base; } }
class BlackFriday implements PricingStrategy {
price(base: number) { return Math.floor(base * 0.5); }
}
class Checkout {
constructor(private strategy: PricingStrategy) {} // swap without touching Checkout
total(base: number) { return this.strategy.price(base); }
}
State lets an object change its behavior when its internal state changes, replacing a sprawling if status == ... with one class per state that knows its own transitions. A vending machine (NoCoin, HasCoin, Dispensing) or an order lifecycle (Placed, Shipped, Delivered) is the canonical case:
interface State {
insertCoin(m: VendingMachine): void;
dispense(m: VendingMachine): void;
}
class NoCoin implements State {
insertCoin(m: VendingMachine) { m.state = new HasCoin(); }
dispense(m: VendingMachine) { console.log("insert a coin first"); }
}
class HasCoin implements State {
insertCoin(m: VendingMachine) { console.log("already have a coin"); }
dispense(m: VendingMachine) { console.log("dispensing"); m.state = new NoCoin(); }
}
class VendingMachine {
state: State = new NoCoin();
insertCoin() { this.state.insertCoin(this); }
dispense() { this.state.dispense(this); }
}
Observer lets many subscribers react to a subject's events without the subject knowing who they are - the basis of notifications and live updates. A subject keeps a list of observers and calls them on change:
interface Observer { update(event: string): void; }
class Subject {
private observers: Observer[] = [];
subscribe(obs: Observer) { this.observers.push(obs); }
notify(event: string) { this.observers.forEach(o => o.update(event)); }
}
Command wraps a request as an object so you can queue it, log it, or undo it (the Redo/Undo stack, task schedulers). Chain of Responsibility passes a request along a chain of handlers until one handles it (middleware, a logging framework's levels). Template Method, Iterator, Mediator, Memento, and Visitor cover the remaining behavioral cases.
If your prep time is limited, learn Strategy, State, Observer, and Factory cold - they cover the large majority of low-level design interviews. Train the recognition: "the algorithm varies" is Strategy, "behavior depends on a status" is State, "many things react to an event" is Observer, "create the right subtype without hard-coding it" is Factory. Recognizing the shape is most of the battle; the code above is the rest.
Where to go deep
This post is the map. The territory - every pattern with thorough explanations, real code in multiple languages, and clear diagrams of the problem each one solves - is covered best in one place: Refactoring Guru's design patterns catalog, refactoring.guru/design-patterns. Work through Strategy, State, Observer, and Factory there, implement each once in your own language, then apply them to the LLD problems (Parking Lot, Vending Machine, Splitwise) - with the principles above as your guide to keeping the classes clean.
With Part 1's architecture vocabulary and this LLD vocabulary in hand, the next post turns to back-of-the-envelope estimation - the math that converts a problem's scale into the numbers that decide which of all these concepts you actually reach for.

