03 — Design Principles for Senior LLD
Why Principles Beat Patterns
Patterns are tactical — a Strategy here, an Observer there. They're a vocabulary for naming shapes you've already decided to build. Principles are prior: they tell you when a pattern is wrong, when to inline what a textbook would abstract, when to duplicate instead of deduplicate. A Strategy pattern applied without tension against SRP or OCP is just extra classes.
Senior interviewers probe principles because they can't be memorized into a working design. You can memorize "Strategy decouples algorithm from client." You cannot memorize when the algorithm will change. That judgment is the job. If a candidate can recite Gang of Four cover to cover but can't explain why a second subclass would be worse than a composed strategy, they're a mid-level engineer with a bigger vocabulary.
How this doc is structured
Every section gives you (1) a one-line definition you could write on a whiteboard, (2) the senior framing — how to talk about it with 6+ YOE judgment, (3) one concrete LLD example, and (4) the common misapplication. We assume you already use these principles; the goal is to sharpen the vocabulary you deploy when an interviewer asks "why did you split that out?"
SOLID
SOLID is old (2000, Robert Martin) and overloaded. Interviewers will quote it, but what they're testing isn't recall — it's whether you can point at one specific line of a design and say "this violates ISP because callers are forced to depend on methods they don't use." Vague incantations fail the bar.
Single Responsibility
Definition. A class should have one reason to change.
Senior framing. "A class should have one axis of change, not one thing it does." SRP is about who asks for changes, not method count. If finance, ops, and marketing can all independently force edits to the same class, you have three responsibilities hiding inside it — even if it's only 40 lines.
Example — parking lot fee calculation.
// Violates SRP: three axes of change bundled together.
class ParkingTicket {
constructor(
public vehicleId: string,
public entryTime: Date,
public exitTime: Date | null,
public spotType: "compact" | "large" | "ev",
) {}
computeFee(): number { // Axis 1: pricing rules (finance)
const hours = Math.ceil(((this.exitTime?.getTime() ?? 0) - this.entryTime.getTime()) / 3.6e6);
const rate = this.spotType === "ev" ? 8 : this.spotType === "large" ? 5 : 3;
return hours * rate;
}
format(): string { // Axis 2: presentation (UI team)
return `Ticket ${this.vehicleId}: $${this.computeFee().toFixed(2)}`;
}
save(db: Database): void { // Axis 3: persistence (platform team)
db.exec("INSERT INTO tickets ...", [this.vehicleId, this.entryTime, this.exitTime]);
}
}Fixed: the ticket is a value object; each responsibility has its own owner — FeeCalculator (pricing), TicketRepository (persistence), TicketFormatter (presentation).
Common misapplication. Splitting classes by method count. A UserService with 12 methods that all revolve around the User aggregate is fine — they share a single axis of change (user domain rules). Splitting it into UserCreator, UserDeleter, UserUpdater is noise, not cohesion. SRP is about change vectors, not size.
When a senior bends SRP
If two "responsibilities" always change together and have never been modified independently in two years of git history, merging them is the right call. Over-splitting creates ceremony — five files, five imports, five test fixtures — for zero actual flexibility. Prefer SRP at the aggregate level, not the method level.
Open/Closed
Definition. Software entities should be open for extension, closed for modification.
Senior framing. OCP is really about protecting stable code from unstable code. You don't open every class for extension — you open the volatile ones. If pricing rules change quarterly but the ticket schema doesn't, you wrap pricing behind a PricingStrategy interface and leave the ticket class alone.
Example — promotion rules in an e-commerce checkout.
// Violates OCP: every new promo requires editing this method.
class Checkout {
computeTotal(cart: Cart, code: string): number {
let total = cart.subtotal();
if (code === "SAVE10") total *= 0.9;
else if (code === "FREESHIP") total -= cart.shipping();
else if (code === "BOGO") total -= cart.cheapestItem().price;
// add a fourth promo → touch Checkout again
return total;
}
}// Honors OCP: Checkout is closed. Adding a promo is a new class.
interface Promotion {
apply(cart: Cart): Money;
}
class PercentOffPromotion implements Promotion { /* ... */ }
class FreeShippingPromotion implements Promotion { /* ... */ }
class BuyOneGetOnePromotion implements Promotion { /* ... */ }
class Checkout {
constructor(private readonly promotions: Map<string, Promotion>) {}
computeTotal(cart: Cart, code: string): Money {
const promo = this.promotions.get(code);
return promo ? cart.subtotal().minus(promo.apply(cart)) : cart.subtotal();
}
}Common misapplication. Pre-emptively opening every class "just in case." The cost is real — every extension point is indirection, an interface to name, a factory to wire. Open code for extension when you've seen the pressure, not prophylactically. YAGNI (below) is OCP's counterweight.
| Signal that OCP is earning its keep | Signal that OCP is theater |
|---|---|
| The behavior has already changed twice. | You added an interface with one implementation, to feel clean. |
| Different teams own different implementations. | You're the only one touching it and expect to stay that way. |
| The implementation has pluggable configuration (pricing, assignment, auth). | The "strategies" differ only by hard-coded constants. |
Liskov Substitution
Definition. Subtypes must be substitutable for their base types without breaking callers.
Senior framing. LSP is about behavioral contracts, not compilation. Square extends Rectangle compiles. It still breaks every caller that assumed setting width and height independently works. LSP violations are the reason inheritance is suspect — you can't always enforce behavior in the type system.
Example — bird hierarchy (the classic, but reframed).
// LSP-violating: Penguin breaks Bird.fly().
abstract class Bird {
abstract fly(): void;
}
class Sparrow extends Bird {
fly() { /* actually flies */ }
}
class Penguin extends Bird {
fly() { throw new Error("Penguins can't fly"); } // breaks callers
}
function migrate(birds: Bird[]) {
for (const b of birds) b.fly(); // throws on penguins
}// LSP-honoring: model the actual capability, not the biological category.
interface Flyer {
fly(): void;
}
class Sparrow implements Flyer { fly() { /* ... */ } }
class Penguin { swim() { /* ... */ } } // not a Flyer
function migrate(flyers: Flyer[]) {
for (const f of flyers) f.fly(); // total
}Common misapplication. Forcing every "is-a" relationship into the type hierarchy. Penguin is-a Bird biologically; Penguin is-a Flyer is a lie. Senior engineers reach for interfaces (capabilities) more than base classes (categories).
The pre/post/invariant rule
A subtype may weaken preconditions (accept more) and strengthen postconditions (promise more), but never the reverse. If Base.pay(amount: PositiveNumber) accepts only positives, Derived.pay(amount: Number) is LSP-safe. If Derived.pay suddenly rejects some values the base accepts, you've broken the contract.
Interface Segregation
Definition. Clients should not be forced to depend on methods they don't use.
Senior framing. Fat interfaces turn into change magnets. Every method on IUserManager is a reason for every client to recompile, re-mock, re-test. Split interfaces by the axis of the caller, not the implementer — a rate-limiter client needs isAllowed(key), not getStats() and clearWindow().
Example — notification service.
// Fat interface: SMS client drags email and push dependencies.
interface INotifier {
sendEmail(to: string, body: string): void;
sendSms(to: string, body: string): void;
sendPush(deviceId: string, body: string): void;
}
class OrderService {
constructor(private readonly notifier: INotifier) {}
onOrderShipped(order: Order) {
this.notifier.sendEmail(order.email, "shipped!");
// OrderService is coupled to sendSms + sendPush it never calls
}
}// Segregated: each caller depends only on what it uses.
interface EmailNotifier { sendEmail(to: string, body: string): void; }
interface SmsNotifier { sendSms(to: string, body: string): void; }
interface PushNotifier { sendPush(deviceId: string, body: string): void; }
class OrderService {
constructor(private readonly notifier: EmailNotifier) {}
}
// One class can implement multiple role interfaces.
class TwilioNotifier implements SmsNotifier { /* ... */ }
class SendgridNotifier implements EmailNotifier { /* ... */ }Common misapplication. Splitting an interface that's always called together. If 90% of callers need both read and write, forcing them to declare two dependencies is ceremony. ISP is about clients that really are distinct, not every method in isolation.
Dependency Inversion
Definition. High-level modules should not depend on low-level modules. Both should depend on abstractions.
Senior framing. DIP is the principle; DI (injection) is one technique for achieving it. The spirit is direction of coupling. Policy (high-level) names the interface; mechanism (low-level) implements it. That way the database doesn't dictate the shape of your service — the service dictates the shape of its repository port.
Example — rate limiter with pluggable storage.
// Violates DIP: RateLimiter knows about Redis directly.
import { RedisClient } from "redis";
class RateLimiter {
constructor(private readonly redis: RedisClient) {}
isAllowed(key: string): boolean {
const count = this.redis.incr(key); // coupled to Redis API
return count <= 100;
}
}// Honors DIP: RateLimiter defines the port; Redis is one adapter.
interface CounterStore {
incr(key: string): number;
expire(key: string, seconds: number): void;
}
class RateLimiter {
constructor(private readonly store: CounterStore) {}
isAllowed(key: string): boolean {
return this.store.incr(key) <= 100;
}
}
class RedisCounterStore implements CounterStore { /* adapts Redis */ }
class InMemoryCounterStore implements CounterStore { /* for tests */ }The CounterStore interface lives with RateLimiter (high-level). RedisCounterStore lives in the adapter layer. Redis doesn't shape the contract; the rate limiter does.
Common misapplication. Creating interfaces for every class "to invert dependencies" without thinking about direction. UserServiceImpl implements UserService where there's one impl, owned by the same team, with no abstraction benefit, is just extra files. DIP earns its keep when the dependency really could go the wrong way — i.e., when the low-level thing is volatile, swappable, or mock-worthy in tests.
| SOLID principle | What senior candidates cite it for |
|---|---|
| SRP | Justifying why a class was split (or why it wasn't). |
| OCP | Defending a Strategy/Policy interface against "why not just if-else?" |
| LSP | Rejecting inheritance that would break caller assumptions. |
| ISP | Explaining why five small interfaces beat one big one. |
| DIP | Naming the port/adapter seam that makes tests fast and storage swappable. |
DRY, KISS, YAGNI
These three are usually framed as rules. A senior engineer treats them as forces — each one has a dual, and the judgment call is which force wins in this specific spot.
DRY — Don't Repeat Yourself
Definition. Every piece of knowledge should have a single, unambiguous representation in the system.
Senior framing. DRY is about knowledge, not code. Two methods that look identical but encode different domain rules aren't a DRY violation — they're a coincidence that will diverge. Deduplicating them creates a coupling the code was never meant to have.
Example — shipping cost and refund processing.
// Both compute a percentage of a number. LOOKS dry-able.
function shippingCost(subtotal: number): number {
return subtotal * 0.08;
}
function refundFee(amount: number): number {
return amount * 0.08;
}
// Tempting "DRY" refactor:
function applyRate(n: number): number { return n * 0.08; }The DRY refactor is wrong because shipping and refund rates are independent policy. When finance drops the refund fee to 5%, the developer editing applyRate has a bad day. DRY applies when two lines encode the same rule; repetition that encodes different rules that currently coincide is not a violation.
When a senior deliberately violates DRY. Test setups, logging preambles, and config scaffolding are often repeated intentionally. A shared setupTestUser() helper saves three lines but couples every test to its internals; when one test needs a different user, you've now got a branchy helper. Copy-paste three lines. It's fine.
KISS — Keep It Simple, Stupid
Definition. The simplest design that satisfies the requirement wins.
Senior framing. KISS beats DRY when the abstraction tax exceeds the duplication tax. Two straight functions are simpler than one generic function with three type parameters and a callback — even when they share code.
Example — authentication middleware.
// Over-engineered: pluggable, generic, composable, extensible... and 60 lines of ceremony.
class AuthMiddlewareBuilder<TToken, TUser> {
withTokenExtractor(fn: (req: Request) => TToken | null): this { /* ... */ return this; }
withUserLoader(fn: (tok: TToken) => Promise<TUser>): this { /* ... */ return this; }
withFallback(fn: (req: Request) => void): this { /* ... */ return this; }
build(): Middleware { /* ... */ }
}
// KISS: 7 lines, obviously correct, trivial to change when the requirement actually arrives.
function authMiddleware(req: Request, res: Response, next: Next) {
const token = req.headers.authorization?.slice("Bearer ".length);
if (!token) return res.status(401).end();
const user = verifyJwt(token);
if (!user) return res.status(401).end();
req.user = user;
next();
}When a senior bends KISS. Genuinely variable behavior — when three products plug in three different auth schemes — wants the builder. KISS is not "avoid abstraction." It's "don't abstract until the variability is real."
YAGNI — You Aren't Gonna Need It
Definition. Don't build what you don't need yet.
Senior framing. YAGNI is shipping discipline. Every speculative feature — "we might want to support multi-tenancy later" — is free in the moment and expensive forever. Senior engineers delete speculation aggressively, because they've been on the receiving end of speculation that was wrong.
Example — rate limiter windows.
The interviewer asks for a fixed-window rate limiter. You say "I'll use a Strategy interface so we can plug in sliding window, token bucket, leaky bucket later." That's three lines of interface, one implementation, and six lines of factory wiring — for future work that may never happen, or may want a totally different shape when it does.
YAGNI says: implement fixed window as a concrete class. If sliding window is requested in three months, then extract the interface. The refactor is 15 minutes. The speculation was five days of "what should the interface look like?"
When a senior bends YAGNI. Security, observability, and data integrity are rarely YAGNI-able. Auth, structured logging, idempotency keys — these are cheap to add early and terrifying to retrofit. A candidate who YAGNIs auth "because we don't need it yet" is flagging a reliability risk, not showing shipping discipline.
| Force | Counter-force | Senior move |
|---|---|---|
| DRY (dedupe) | KISS (don't abstract two coincidences) | Dedupe only when the rule is shared; copy when it happens to look similar. |
| KISS (simplify) | OCP (open for extension) | Extend when you've seen the pressure twice; stay simple otherwise. |
| YAGNI (delete speculation) | Observability/Security (cheap now, expensive later) | YAGNI business features; never YAGNI logging, metrics, auth, idempotency. |
Cohesion and Coupling
These are the oldest ideas in object design (Constantine, 1974) and still the most useful. They're the quantitative version of SRP and DIP — you can look at a class and ask "how tightly does its stuff belong together?" and "how hard is this class to replace?"
High Cohesion
Definition. Things inside a module belong together because they serve a single purpose.
Senior framing. Cohesion is a spectrum, not a boolean. The cohesion types (below) give you language for why one grouping is better than another. An interviewer who asks "why is this method on that class?" is probing cohesion.
| Cohesion type | Description | Example | Quality |
|---|---|---|---|
| Coincidental | Unrelated things lumped together | Utils with formatDate, parseCsv, hashPassword | Worst |
| Logical | Things categorized by kind, not purpose | InputReader that reads files, keyboards, and sockets via a flag | Bad |
| Temporal | Things that happen at the same time | ShutdownHandler that closes DB, flushes logs, notifies Slack | Weak |
| Procedural | Steps of a procedure, sharing control flow but not data | RequestPipeline with auth, parse, dispatch in sequence | OK |
| Communicational | Operates on the same data | Report class with render, export, email on one report | Good |
| Sequential | Output of one step feeds the next | VideoTranscoder with decode → filter → encode | Good |
| Functional | Everything contributes to one well-defined task | PasswordHasher with hash and verify | Best |
Example — parking lot admin service.
// Coincidental cohesion: what does this class "do"? Whatever you dump into it.
class ParkingLotUtils {
computeFee(ticket: Ticket): number { /* ... */ }
sendReceiptEmail(ticket: Ticket): void { /* ... */ }
archiveOldTickets(): void { /* ... */ }
loadSpotLayout(): SpotLayout { /* ... */ }
}Four responsibilities, four axes of change, one file. The test file already needs four sets of mocks. Split into FeeCalculator, ReceiptMailer, TicketArchiver, LayoutLoader — each functionally cohesive.
Low Coupling
Definition. Modules should know as little as possible about each other.
Senior framing. Coupling is the surface area between modules. Two modules that exchange one integer are less coupled than two modules that share a 30-field struct. Low coupling is what makes a change local — a change with low coupling rewrites one file; with high coupling, a dozen.
| Coupling type | Description | Severity |
|---|---|---|
| Content coupling | Module A reads/writes module B's internals | Worst |
| Common coupling | Modules share global state | Very bad |
| External coupling | Modules share an external format (protocol, file) | Depends |
| Control coupling | A passes a flag that directs B's behavior | Bad |
| Stamp coupling | A passes B a big struct when B only needs one field | Mediocre |
| Data coupling | A passes B exactly the data it needs | Good |
| Message coupling | A calls B's methods via a thin interface; no shared data | Best |
Why interviewers probe this with "why is this in the same class as that?" They're not asking for a justification of the line of code — they're asking whether you can see the axes of change and the knowledge boundaries. A senior answer names the cohesion type ("these are functionally cohesive — both operate on the parking rate table") or admits the split ("you're right, archive doesn't belong here — I'd pull it into a TicketArchiver").
The rule of thumb
High cohesion within, low coupling between. A module should be densely connected internally (everything inside serves the shared purpose) and sparsely connected externally (only the bare minimum crosses the boundary). When these fight — a cohesive module needs a lot from outside — you've drawn the boundary in the wrong place.
Encapsulation (Beyond private)
Definition at the textbook level. Hide internals behind an interface.
Senior framing. Encapsulation is about maintaining invariants, not hiding fields. A class is well-encapsulated when no caller can put it into an illegal state — not when its fields happen to be marked private. private is a syntactic marker; the invariant is the real thing.
Example — the "leaky private" pattern.
// Looks encapsulated. Isn't. The invariant "_total equals sum of items" leaks through getItems().
class Cart {
private items: Item[] = [];
private _total: number = 0;
getItems(): Item[] { return this.items; } // hands out the internal array
getTotal(): number { return this._total; }
}
const cart = new Cart();
cart.getItems().push(new Item("free beer", 0)); // invariant broken without touching privatesProperly encapsulated: all mutation goes through methods that preserve the invariant; reads return readonly views.
class Cart {
private items: Item[] = [];
private _total: Money = Money.zero();
addItem(item: Item): void {
this.items.push(item);
this._total = this._total.plus(item.price);
}
itemsView(): ReadonlyArray<Item> { return this.items; }
total(): Money { return this._total; }
}The senior test. Point at any public method and ask: can this method leave the object in a state it shouldn't be in? If yes, the invariant is leaky. private didn't save you. Patterns that preserve invariants:
| Pattern | How it preserves invariants |
|---|---|
| Return copies or readonly views | Callers can't mutate internals through returned references. |
Make fields final/readonly | Invariants that hold at construction hold forever. |
| Validate in the constructor | No one can construct an invalid instance. |
| Throw on invalid mutations | The object refuses illegal transitions. |
| Use value objects for compound fields | Money, Duration, Percent carry their own invariants. |
Common misapplication. Getters and setters for every field. A class with getX/setX for all internals is a struct wearing a class suit — callers can put it into any state they want, one field at a time. That's the opposite of encapsulation.
Composition Over Inheritance
Definition. Build objects by composing smaller objects ("has-a") rather than extending a base class ("is-a").
Senior framing. Inheritance couples you to a base class's implementation, not just its interface. Any change to the base ripples to every subclass. Composition couples you only to an interface — you own the relationship. Senior engineers reach for composition reflexively because they've been bitten by the fragile base class problem in real code.
Example — report generation with headers, filters, exporters.
Inheritance-first version:
abstract class Report {
protected abstract fetchRows(): Row[];
render(): string {
let out = this.header();
for (const row of this.applyFilters(this.fetchRows())) out += this.formatRow(row);
return out;
}
protected header(): string { return "default header\n"; }
protected applyFilters(rows: Row[]): Row[] { return rows; }
protected formatRow(row: Row): string { return row.toString() + "\n"; }
}
class SalesReport extends Report { /* ... */ }
class SalesReportCsv extends SalesReport { /* override formatRow */ }
class SalesReportCsvWithDateFilter extends SalesReportCsv { /* override applyFilters */ }
// Three levels deep and still can't easily combine "PDF export + date filter"The diamond problem: what if you want "CSV export + date filter + custom header"? Multiple inheritance is either forbidden (Java, C#) or nasty (C++). Even where available, every subclass captures one specific combination of the orthogonal choices.
Composition-first version:
interface RowSource { fetch(): Row[]; }
interface RowFilter { apply(rows: Row[]): Row[]; }
interface RowFormatter { format(row: Row): string; }
interface Header { render(): string; }
class Report {
constructor(
private readonly source: RowSource,
private readonly filter: RowFilter,
private readonly formatter: RowFormatter,
private readonly header: Header,
) {}
render(): string {
let out = this.header.render();
for (const row of this.filter.apply(this.source.fetch())) out += this.formatter.format(row);
return out;
}
}
// Combinations are data, not classes.
const salesCsvWithDateFilter = new Report(
new SalesRowSource(),
new DateRangeFilter(start, end),
new CsvFormatter(),
new SalesHeader(),
);Three orthogonal choices → one class, data-driven. The class hierarchy version needed 2^3 = 8 classes to cover every combination.
| Inheritance | Composition |
|---|---|
| Reuse by extending | Reuse by delegation |
| Tight coupling to base impl | Coupling only to injected interface |
| "Is-a" (kinds) | "Has-a" (capabilities) |
| Breaks on base changes (fragile base class) | Stable across injected-impl changes |
| One-dimensional hierarchy | N-dimensional: compose any set of axes |
| Compile-time choice | Runtime choice |
The fragile base class problem. A change in a base class can silently break subclasses even when the base's public interface is unchanged. Example: the base adds a call to an overridable method in its constructor. Subclasses that relied on being fully constructed when that method runs now fail. Composition doesn't have this — you talk to the injected object through its public interface, and implementation changes that don't change the interface can't break you.
When a senior uses inheritance anyway. Genuine "is-a" with a stable base and tightly scoped override points. Framework-style subclassing (extends React.Component, extends AbstractHandler) works because the framework author controls the base and documents the override contract. Reach for inheritance when (1) it's is-a, not has-a, (2) the base is stable, and (3) there's no plausible multi-axis extension.
Dependency Inversion vs Dependency Injection
These terms are used interchangeably and shouldn't be.
| Dependency Inversion (DIP) | Dependency Injection (DI) | |
|---|---|---|
| What it is | A principle about direction of coupling | A technique for providing dependencies |
| Scope | Architectural | Mechanical |
| Asserts | "High-level modules don't depend on low-level modules" | "Don't new your dependencies; accept them" |
| Can you do one without the other? | Yes — you can inject without inverting (e.g., inject a concrete class) | Yes — you can invert without DI (e.g., a service locator) |
| Interview probe | "Why does the RateLimiter not know about Redis?" | "How does the RateLimiter get its store?" |
DIP is what — the shape of your dependency graph. DI is how — the mechanism by which dependencies reach the classes that need them. Confusing them sounds junior.
Example — the difference in code.
// DI but NOT DIP: RateLimiter is injected with a concrete Redis client.
// High-level module (RateLimiter) still depends on a low-level module (Redis).
class RateLimiter {
constructor(private readonly redis: RedisClient) {} // concrete
}
// DIP but NOT DI: high-level doesn't depend on low-level, but we use a locator.
class RateLimiter {
isAllowed(key: string): boolean {
const store = ServiceLocator.get<CounterStore>("counter"); // interface, but pulled
return store.incr(key) <= 100;
}
}
// Both: the ideal. Interface + constructor injection.
class RateLimiter {
constructor(private readonly store: CounterStore) {} // abstract + injected
}Forms of Injection
| Form | How it works | Pros | Cons |
|---|---|---|---|
| Constructor | Dependencies passed to the constructor | Required deps are enforced at construction. Object is always fully formed. Immutable deps. | Long constructor signatures if deps are many (often a smell). |
| Setter / Property | Dependencies assigned after construction | Optional deps. Reconfigurable at runtime. | Object can exist in a half-initialized state. Forgotten setters cause NPEs. |
| Field / Annotation | Framework (e.g., @Autowired) populates fields via reflection | Minimal boilerplate in Spring-style apps | Hides the dependency graph. Hard to instantiate without the framework (tests suffer). |
Senior default. Constructor injection for required deps. Setter injection only when the dep is genuinely optional. Field injection only if the framework makes it the path of least resistance and your tests can live with it.
The constructor-argument-count smell
If you find yourself injecting 8 dependencies into one constructor, the class is doing too much — that's SRP complaining through DI. Split the class before you split the constructor.
Separation of Concerns
Definition. A program should be divided into distinct sections, each addressing a separate concern.
Senior framing. Two orthogonal axes:
- Horizontal separation — layers. Transport (HTTP/gRPC), application (use cases), domain (business rules), persistence (DB).
- Vertical separation — features. The order module, the catalog module, the auth module.
Both are useful. Monoliths typically start horizontal-first (the classic 3-tier layers). Well-factored large systems are primarily vertical (feature/bounded context modules) with each module internally horizontal.
Example — a checkout service.
checkout/ ← vertical slice (feature)
api/ ← horizontal: transport
CheckoutController.ts
application/ ← horizontal: use cases
PlaceOrderUseCase.ts
domain/ ← horizontal: business rules
Order.ts
PricingPolicy.ts
infrastructure/ ← horizontal: persistence / external
PostgresOrderRepository.ts
StripePaymentGateway.tsThe common interview application. Separating business logic from transport/persistence is the #1 concern-mixing LLD mistake. A candidate who puts SQL queries inside the Order domain class will eventually face:
- "What if we switch from Postgres to DynamoDB?" → rewrite the domain class
- "What if we add a gRPC endpoint?" → duplicate the logic
- "How do you unit test this without a DB?" → you don't
// Mixed concerns: business rule tangled with transport and persistence.
class OrderController {
async placeOrder(req: Request, res: Response) {
const items = req.body.items;
if (items.length === 0) return res.status(400).send("empty cart");
const total = items.reduce((s, i) => s + i.price * i.qty, 0);
if (total > 10000) return res.status(400).send("over limit");
await db.query("INSERT INTO orders ...", [req.user.id, total]);
res.json({ total });
}
}// Separated: each layer has one job. Business rules testable without HTTP or a DB.
class PlaceOrderUseCase {
constructor(private readonly orders: OrderRepository, private readonly policy: OrderPolicy) {}
async execute(userId: UserId, cart: Cart): Promise<Order> {
this.policy.validate(cart); // domain rule
const order = Order.fromCart(userId, cart); // domain object
await this.orders.save(order); // persistence port
return order;
}
}
class OrderController { // transport layer only — translates HTTP to use-case calls
constructor(private readonly useCase: PlaceOrderUseCase) {}
async placeOrder(req: Request, res: Response) {
try {
const order = await this.useCase.execute(req.user.id, Cart.fromJson(req.body));
res.json(order.toJson());
} catch (e) {
if (e instanceof DomainError) return res.status(400).send(e.message);
throw e;
}
}
}Immutability
Definition. Once created, an object's state cannot change.
Senior framing. Immutability is cheap insurance against a whole class of bugs: aliasing, concurrent mutation, accidental state leaks through shared references. The memory cost (allocating new instances on "modification") is usually negligible compared to the bugs it prevents.
When it's worth the memory cost.
- Value objects.
Money,Duration,Coordinate,EmailAddress. These should always be immutable. Their identity is their value, so mutation would be incoherent — you'd change what they are. - Shared state in concurrency. An immutable
Configobject doesn't need a lock. An immutableSnapshotcan be safely published to N threads. - Undo/history features. Each state snapshot is retained; immutability makes the history cost-free to keep.
When you pay the memory cost grudgingly.
- Large aggregates. A 10,000-line document that re-allocates the whole tree on every keystroke is impractical. Persistent data structures (tries, HAMTs) share structure between versions and sidestep this — but they're complex.
- Hot paths. Game engines, ray tracers, anything in a tight loop. Mutation is often the right call.
Example — value objects vs entities.
// Value object: immutable. Two Moneys with the same currency and amount ARE the same Money.
class Money {
constructor(
public readonly amount: number,
public readonly currency: Currency,
) {
Object.freeze(this);
}
plus(other: Money): Money {
if (other.currency !== this.currency) throw new Error("currency mismatch");
return new Money(this.amount + other.amount, this.currency);
}
equals(other: Money): boolean {
return this.amount === other.amount && this.currency === other.currency;
}
}
// Entity: mutable. Two Orders with identical fields are NOT the same order; identity is the id.
class Order {
private items: Item[] = [];
constructor(public readonly id: OrderId) {}
addItem(item: Item): void {
this.items.push(item); // mutation is expected
}
}Concurrency implications. Immutable objects are inherently thread-safe — no two threads can race on a read-only object. This makes immutable value objects the right unit to pass across concurrency boundaries (thread pools, goroutines, async boundaries). The entity pattern — mutable object guarded by synchronization — is fine inside one thread's ownership but dangerous when shared.
| Property | Value object | Entity |
|---|---|---|
| Identity | Structural (by value) | Referential (by id) |
| Mutation | Forbidden | Expected |
| Equality | Compare fields | Compare ids |
| Thread safety | Inherent | Requires synchronization |
| Lifetime | Typically short (derived from entities) | Typically persistent (saved to DB) |
| Example | Money, Address, DateRange | Order, User, Account |
The Interview Heuristic
Interviewers rarely quote principles by name. They probe them through questions about change, growth, failure, and scope. A senior candidate hears the principle under the question and cites it in the response.
| Interviewer probe | Principle(s) to cite | Move to make |
|---|---|---|
| "What if the pricing logic changes every quarter?" | OCP + Strategy | Extract PricingStrategy; show that adding a new rule is a new class, not an edit. |
| "What if we add SMS notifications later?" | ISP + OCP | Segregate Notifier role interfaces; add SmsNotifier without touching the email path. |
| "Why is this method on this class?" | SRP + Cohesion | Name the axis of change ("finance owns fee rules") or admit it belongs elsewhere. |
| "How do you test this without a database?" | DIP + DI | Point at the repository port; inject an in-memory fake in tests. |
| "What happens if a subclass does X?" | LSP | Explain the behavioral contract; refactor to interface + composition if inheritance is lying. |
| "Can this class be put into an illegal state?" | Encapsulation | Walk through each public method; name the invariant each one preserves. |
| "Why did you put auth in the middleware instead of the handler?" | Separation of Concerns | Horizontal layers: handler = business logic, middleware = cross-cutting auth. |
| "What if two threads call this at once?" | Immutability or explicit locking | Prefer immutability for values; for entities, name the contended resource + locking strategy. |
| "Why an interface with one implementation?" | YAGNI vs DIP tension | Justify the specific pressure (testability, swappability). If you can't, collapse to concrete. |
| "Isn't this overengineered?" | KISS + YAGNI | Identify the abstraction tax; propose the simpler form. Don't defend complexity you don't need. |
| "Why did you duplicate that logic?" | DRY vs KISS | Explain whether the duplication encodes the same rule (deduplicate) or a coincidence (leave it). |
| "What if we need to swap the database?" | DIP | Point at the repository port; the domain doesn't know about the DB. |
| "What breaks if the base class changes?" | Composition over Inheritance | Show composition's stability; acknowledge the fragile-base-class risk in the inheritance version. |
| "How many things does this class do?" | SRP + Cohesion | Count axes of change, not methods. Functional cohesion is the target. |
| "What fails if this runs concurrently?" | Immutability + Encapsulation | Immutable values cross boundaries freely; mutable entities stay inside a synchronization scope. |
The senior move, one sentence
Cite the principle by name, explain the specific pressure it absorbs, acknowledge when you'd bend it. That three-beat answer — name + pressure + bend — is the difference between reciting a textbook and sounding like someone who has run this play at work.
What is Expected at Each Level
| Level | Expectations |
|---|---|
| Junior | Recites SOLID, DRY, KISS by definition. Applies them somewhat mechanically — splits classes, adds interfaces, dedupes code. Tends to over-apply (interface for every class) or under-apply (god class). |
| Mid-level | Names the principle behind a design choice when asked. Picks patterns that honor principles (Strategy for OCP, Adapter for DIP). Recognizes when DRY hurts (coincidental duplication) vs helps. |
| Senior | Principles are the default vocabulary, not a recital. Cites principles proactively when defending designs. Knows where to bend each one — copies three lines of test setup without guilt, YAGNIs speculative interfaces, lets a class have 10 methods if they share an axis. Distinguishes DIP (principle) from DI (technique). Names the pressure (axis of change, swappability, invariant, concurrency boundary) that justifies each abstraction. Acknowledges the cost of every principle application — interfaces are indirection, immutability is memory, composition is wiring — and balances that cost against the pressure. |