GAP·MAP

SOLID principles

[ junior depth ]

Start with the smells

SOLID names five design pressures. The useful question is not “where can I add an interface?” It is “what kind of change or invalid promise is making this code expensive?”

PrincipleSmell it addresses
Single responsibilityOne unit changes for unrelated reasons
Open-closedAdding a known variant requires editing tested branching code
Liskov substitutionAn implementation matches the type but surprises the caller
Interface segregationA client or implementer depends on operations it does not need
Dependency inversionBusiness code imports or constructs infrastructure details

Single responsibility means one reason to change

A class can have several methods and still have one responsibility. Microsoft’s guidance describes the target as one responsibility and one reason to change. Its Open Closed Principle article adds that the class should contain a cohesive set of related functions.

This service mixes pricing policy with receipt formatting:

class InvoiceService {
  total(lines: Line[]): number {
    return lines.reduce((sum, line) => sum + line.price, 0);
  }

  receipt(lines: Line[]): string {
    return lines.map((line) => `${line.name}: ${line.price}`).join("\n");
  }
}

A tax rule and a receipt layout now change the same class. Separate the reasons:

class InvoiceCalculator {
  total(lines: Line[]): number {
    return lines.reduce((sum, line) => sum + line.price, 0);
  }
}

class ReceiptFormatter {
  format(lines: Line[]): string {
    return lines.map((line) => `${line.name}: ${line.price}`).join("\n");
  }
}

Wrong “Single responsibility means one method per class.”

Right Group cohesive operations that change for the same reason. File length can be a warning, but it does not define the principle.

Open-closed targets repeated modification

Software is open for extension and closed for modification when a new supported variant can be added with minimal changes to existing code. A growing type switch is the usual clue:

function shippingCost(kind: "standard" | "express", weight: number): number {
  if (kind === "standard") return weight * 1.5;
  return weight * 3;
}

If carriers or policies keep arriving, move the varying calculation behind a contract:

interface ShippingPolicy {
  cost(weight: number): number;
}

class StandardShipping implements ShippingPolicy {
  cost(weight: number): number {
    return weight * 1.5;
  }
}

function quote(policy: ShippingPolicy, weight: number): number {
  return policy.cost(weight);
}

Adding another policy leaves quote unchanged. This does not mean existing code is sacred. Microsoft’s guidance presents OCP as a design vector and warns that making every possible change pluggable produces an over-designed system.

Subtypes must keep the promise

Liskov substitution asks whether any implementation of an abstraction can be used wherever that abstraction is accepted. Matching method names is insufficient when behavior differs:

interface FileStore {
  save(path: string, bytes: Uint8Array): Promise<void>;
}

class ReadOnlyStore implements FileStore {
  async save(): Promise<void> {
    throw new Error("read only");
  }
}

ReadOnlyStore has the required method, yet callers cannot use it as a store that promises to save. Give read-only and writable capabilities different contracts:

interface FileReader {
  read(path: string): Promise<Uint8Array>;
}

interface FileWriter {
  save(path: string, bytes: Uint8Array): Promise<void>;
}

Wrong “If TypeScript accepts implements, Liskov substitution is guaranteed.”

Right TypeScript uses structural compatibility and documents places where its type system permits unsound behavior. The compiler checks type relationships, while your contract must also state what accepted inputs, results, and failures mean.

The last two letters deal with contract size and dependency direction. They are developed in the middle notes.

[ middle depth ]

Interface segregation prevents fake capabilities

Microsoft’s SOLID guidance says each interface should have a specific purpose and implementers should not be forced to implement an interface when they do not share that purpose. Large interfaces often expose operations that some implementations cannot perform.

interface DocumentStore {
  read(id: string): Promise<string>;
  write(id: string, text: string): Promise<void>;
  delete(id: string): Promise<void>;
}

class Archive implements DocumentStore {
  async read(id: string): Promise<string> {
    return loadArchivedDocument(id);
  }

  async write(): Promise<void> {
    throw new Error("archive is immutable");
  }

  async delete(): Promise<void> {
    throw new Error("archive is immutable");
  }
}

The interface makes Archive advertise operations it cannot honor. Split contracts by capability and let each consumer request the smallest useful one:

interface DocumentReader {
  read(id: string): Promise<string>;
}

interface DocumentWriter {
  write(id: string, text: string): Promise<void>;
}

interface DocumentRemover {
  delete(id: string): Promise<void>;
}

class Archive implements DocumentReader {
  async read(id: string): Promise<string> {
    return loadArchivedDocument(id);
  }
}

async function renderDocument(reader: DocumentReader, id: string) {
  return reader.read(id);
}

This change helps callers too. renderDocument no longer depends on mutation operations it never calls.

Wrong “Interface segregation means every method needs its own interface.”

Right Split interfaces where clients have different purposes or implementations support different capabilities. A cohesive interface with several methods is fine.

Dependency inversion changes who owns the contract

Microsoft defines dependency inversion as directing dependencies toward abstractions rather than implementation details. The important move happens at compile time: high-level application policy consumes a contract, and the infrastructure adapter implements that contract.

Before, business code selects and constructs a database detail:

class CheckoutService {
  async complete(order: Order): Promise<void> {
    const store = new PostgresOrderStore(process.env.DATABASE_URL!);
    await store.save(order);
  }
}

After, the application owns the operation it needs:

interface OrderStore {
  save(order: Order): Promise<void>;
}

class CheckoutService {
  constructor(private readonly orders: OrderStore) {}

  async complete(order: Order): Promise<void> {
    await this.orders.save(order);
  }
}

class PostgresOrderStore implements OrderStore {
  async save(order: Order): Promise<void> {
    await postgres.insert("orders", order);
  }
}

const checkout = new CheckoutService(new PostgresOrderStore());

CheckoutService now depends on the application’s OrderStore contract. The adapter depends on that same contract and contains the database-specific work. Microsoft’s architecture guide describes this arrangement as inverting the usual compile-time dependency while runtime execution still reaches the concrete implementation.

Dependency injection is how the example supplies PostgresOrderStore. Microsoft documents DI as a loose-coupling technique based on dependency inversion. The terms are related, but they describe different things.

Wrong “Constructor injection automatically satisfies dependency inversion.”

Right Passing PostgresOrderStore into a constructor makes the dependency explicit, but a parameter typed as PostgresOrderStore still couples application code to that implementation. Check which layer owns the contract and where compile-time dependencies point.

Liskov substitution is a behavioral review

TypeScript compares ordinary public object types structurally. Extra members are allowed when the target’s required members are present and compatible. That makes small capability interfaces natural, without requiring a shared base class.

Structural compatibility does not establish the whole runtime contract:

interface Parser {
  parse(input: string): Result;
}

class JsonObjectParser implements Parser {
  parse(input: string): Result {
    const value = JSON.parse(input);
    if (Array.isArray(value)) throw new Error("objects only");
    return value;
  }
}

If Parser promises all valid JSON, JsonObjectParser accepts fewer inputs. A caller substituting it can fail even though the shape matches. Either implement the original promise or narrow and rename the contract so the restriction is visible:

interface JsonObjectParser {
  parseObject(input: string): Record<string, unknown>;
}

Interviewers often combine LSP and ISP here. Unsupported methods indicate a contract split; a subtype that silently changes accepted inputs or result meaning indicates broken substitutability.

The principles overlap without becoming synonyms

The OrderStore redesign can satisfy several pressures at once. A narrow contract supports ISP. A valid alternative store supports LSP. Adding that store without editing checkout supports OCP. Application ownership of the contract supports DIP. SRP still asks a separate question: does each unit contain cohesive behavior with one reason to change?

[ senior depth ]

Contracts are more than TypeScript shapes

TypeScript’s handbook says compatibility is structural and explicitly notes that the type system permits some operations that cannot be known safe at compile time. A senior SOLID review therefore writes down behavioral expectations that member comparison cannot express.

Consider a job queue:

interface JobQueue {
  enqueue(job: Job): Promise<void>;
}

async function accept(queue: JobQueue, job: Job): Promise<void> {
  await queue.enqueue(job);
  await markAccepted(job.id);
}

The signature leaves crucial questions unanswered. Does fulfillment mean the job is durably accepted? Which errors may reject? May the implementation discard duplicates? Two structurally compatible queues can give accept different correctness properties.

A contract can expose the distinction in its result:

type EnqueueResult =
  | { status: "accepted"; jobId: string }
  | { status: "duplicate"; jobId: string };

interface DurableJobQueue {
  enqueue(job: Job): Promise<EnqueueResult>;
}

async function accept(queue: DurableJobQueue, job: Job): Promise<void> {
  const result = await queue.enqueue(job);
  await markAccepted(result.jobId);
}

LSP is violated when an implementation strengthens caller preconditions or fails to preserve promised results and effects. Microsoft’s OCP guidance gives the same practical test: a consumer should know only the public contract, without downcasts or implementation-specific exception handling.

Variance can expose an unsafe substitute

TypeScript has checked function-type parameter positions contravariantly under strictFunctionTypes since TypeScript 2.6, and the option is part of strict. A handler that accepts only a narrower input is unsafe where callers may supply the broader type:

type AppEvent = { id: string };
type PaymentEvent = AppEvent & { amount: number };

let handleAny: (event: AppEvent) => void;
const handlePayment = (event: PaymentEvent) => console.log(event.amount);

handleAny = handlePayment; // Error under strictFunctionTypes

The official release notes document an exception: method and constructor declarations remain bivariant. Avoid treating compiler acceptance as proof of behavioral substitutability, especially when method parameters or other documented unsound allowances are involved.

Put the abstraction at the policy boundary

This code uses injection but still points policy at a vendor detail:

class BillingService {
  constructor(private readonly stripe: StripeClient) {}
}

Renaming the parameter does not invert anything. Define the operation in application language, then adapt the vendor API:

interface PaymentCollector {
  collect(invoice: Invoice): Promise<PaymentReceipt>;
}

class BillingService {
  constructor(private readonly payments: PaymentCollector) {}

  collect(invoice: Invoice): Promise<PaymentReceipt> {
    return this.payments.collect(invoice);
  }
}

class StripePaymentCollector implements PaymentCollector {
  constructor(private readonly stripe: StripeClient) {}

  async collect(invoice: Invoice): Promise<PaymentReceipt> {
    const charge = await this.stripe.charges.create(toStripeCharge(invoice));
    return { id: charge.id };
  }
}

The composition root may know both sides because it constructs the running application. Microsoft’s architecture guide likewise places implementation wiring at startup while keeping application code dependent on core interfaces.

Wrong “DIP forbids new and concrete types.”

Right Microsoft’s SOLID guidance distinguishes values with no dependencies, which are typically instantiated directly, from services and the dependencies they require. Pay attention to services with side effects and implementation-specific dependencies.

Where SOLID becomes expensive

OCP is the easiest principle to over-apply. Microsoft’s guidance warns that making everything that might change fully pluggable creates a system that is difficult to work with. Add an extension point after you can name the variation it isolates.

Speculative design:

interface UserNameFormatterFactory {
  create(strategy: UserNameFormattingStrategy): UserNameFormatter;
}

interface UserNameFormatter {
  format(name: string): string;
}

If the product has one fixed display rule, direct code carries less indirection:

function formatUserName(name: string): string {
  return name.trim();
}

Split when independent reasons to change appear, or when a second implementation reveals a stable boundary. Do not merge unrelated behavior merely to remove duplication. Microsoft’s architectural principles make the sharp version explicit: duplication is preferable to coupling code to the wrong abstraction.

Interfaces also have a maintenance cost. A one-implementation interface can be useful at a real infrastructure boundary, but wrapping every value object, pure helper, and stable data shape creates navigation overhead without changing dependency direction. SOLID supplies questions for a design review. It does not supply a target number of classes.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Architecture

was this useful?