Skip to content

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.

typescript
// 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.

typescript
// 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;
  }
}
typescript
// 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 keepSignal 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).

typescript
// 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
}
typescript
// 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.

typescript
// 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
  }
}
typescript
// 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.

typescript
// 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;
  }
}
typescript
// 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 principleWhat senior candidates cite it for
SRPJustifying why a class was split (or why it wasn't).
OCPDefending a Strategy/Policy interface against "why not just if-else?"
LSPRejecting inheritance that would break caller assumptions.
ISPExplaining why five small interfaces beat one big one.
DIPNaming 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.

typescript
// 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.

typescript
// 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.

ForceCounter-forceSenior 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 typeDescriptionExampleQuality
CoincidentalUnrelated things lumped togetherUtils with formatDate, parseCsv, hashPasswordWorst
LogicalThings categorized by kind, not purposeInputReader that reads files, keyboards, and sockets via a flagBad
TemporalThings that happen at the same timeShutdownHandler that closes DB, flushes logs, notifies SlackWeak
ProceduralSteps of a procedure, sharing control flow but not dataRequestPipeline with auth, parse, dispatch in sequenceOK
CommunicationalOperates on the same dataReport class with render, export, email on one reportGood
SequentialOutput of one step feeds the nextVideoTranscoder with decode → filter → encodeGood
FunctionalEverything contributes to one well-defined taskPasswordHasher with hash and verifyBest

Example — parking lot admin service.

typescript
// 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 typeDescriptionSeverity
Content couplingModule A reads/writes module B's internalsWorst
Common couplingModules share global stateVery bad
External couplingModules share an external format (protocol, file)Depends
Control couplingA passes a flag that directs B's behaviorBad
Stamp couplingA passes B a big struct when B only needs one fieldMediocre
Data couplingA passes B exactly the data it needsGood
Message couplingA calls B's methods via a thin interface; no shared dataBest

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.

typescript
// 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 privates

Properly encapsulated: all mutation goes through methods that preserve the invariant; reads return readonly views.

typescript
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:

PatternHow it preserves invariants
Return copies or readonly viewsCallers can't mutate internals through returned references.
Make fields final/readonlyInvariants that hold at construction hold forever.
Validate in the constructorNo one can construct an invalid instance.
Throw on invalid mutationsThe object refuses illegal transitions.
Use value objects for compound fieldsMoney, 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:

typescript
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:

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

InheritanceComposition
Reuse by extendingReuse by delegation
Tight coupling to base implCoupling only to injected interface
"Is-a" (kinds)"Has-a" (capabilities)
Breaks on base changes (fragile base class)Stable across injected-impl changes
One-dimensional hierarchyN-dimensional: compose any set of axes
Compile-time choiceRuntime 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 isA principle about direction of couplingA technique for providing dependencies
ScopeArchitecturalMechanical
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.

typescript
// 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

FormHow it worksProsCons
ConstructorDependencies passed to the constructorRequired deps are enforced at construction. Object is always fully formed. Immutable deps.Long constructor signatures if deps are many (often a smell).
Setter / PropertyDependencies assigned after constructionOptional deps. Reconfigurable at runtime.Object can exist in a half-initialized state. Forgotten setters cause NPEs.
Field / AnnotationFramework (e.g., @Autowired) populates fields via reflectionMinimal boilerplate in Spring-style appsHides 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.ts

The 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
typescript
// 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 });
  }
}
typescript
// 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 Config object doesn't need a lock. An immutable Snapshot can 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.

typescript
// 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.

PropertyValue objectEntity
IdentityStructural (by value)Referential (by id)
MutationForbiddenExpected
EqualityCompare fieldsCompare ids
Thread safetyInherentRequires synchronization
LifetimeTypically short (derived from entities)Typically persistent (saved to DB)
ExampleMoney, Address, DateRangeOrder, 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 probePrinciple(s) to citeMove to make
"What if the pricing logic changes every quarter?"OCP + StrategyExtract PricingStrategy; show that adding a new rule is a new class, not an edit.
"What if we add SMS notifications later?"ISP + OCPSegregate Notifier role interfaces; add SmsNotifier without touching the email path.
"Why is this method on this class?"SRP + CohesionName the axis of change ("finance owns fee rules") or admit it belongs elsewhere.
"How do you test this without a database?"DIP + DIPoint at the repository port; inject an in-memory fake in tests.
"What happens if a subclass does X?"LSPExplain the behavioral contract; refactor to interface + composition if inheritance is lying.
"Can this class be put into an illegal state?"EncapsulationWalk through each public method; name the invariant each one preserves.
"Why did you put auth in the middleware instead of the handler?"Separation of ConcernsHorizontal layers: handler = business logic, middleware = cross-cutting auth.
"What if two threads call this at once?"Immutability or explicit lockingPrefer immutability for values; for entities, name the contended resource + locking strategy.
"Why an interface with one implementation?"YAGNI vs DIP tensionJustify the specific pressure (testability, swappability). If you can't, collapse to concrete.
"Isn't this overengineered?"KISS + YAGNIIdentify the abstraction tax; propose the simpler form. Don't defend complexity you don't need.
"Why did you duplicate that logic?"DRY vs KISSExplain whether the duplication encodes the same rule (deduplicate) or a coincidence (leave it).
"What if we need to swap the database?"DIPPoint at the repository port; the domain doesn't know about the DB.
"What breaks if the base class changes?"Composition over InheritanceShow composition's stability; acknowledge the fragile-base-class risk in the inheritance version.
"How many things does this class do?"SRP + CohesionCount axes of change, not methods. Functional cohesion is the target.
"What fails if this runs concurrently?"Immutability + EncapsulationImmutable 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

LevelExpectations
JuniorRecites 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-levelNames 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.
SeniorPrinciples 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.

Frontend interview preparation reference.