Siddhant Deval
Siddhant Deval
system design19 min read

SOLID Principles: Structural Foundations (S, O, L)

SRP, OCP, and LSP are the structural load-bearing principles of every codebase that can grow without breaking. This article goes beyond definitions to show their mechanical enforcement in TypeScript 5+: dismantling god-classes, building strategy registries with `satisfies`, and using `--strictFunctionTypes` to catch Liskov violations at compile time.

Series·Part 6 of 13

TypeScript Low-Level Design & Object-Oriented Architecture

SOLID Principles: Structural Foundations (S, O, L)

In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. SOLID principles are the engineering grammar of that contract — the specific, mechanically enforceable rules that determine whether a codebase can grow without breaking.

This article covers the three structural principles — SRP, OCP, and LSP — that form the load-bearing foundation. They are not philosophical guidelines about "clean code." Each one has a concrete violation pattern that produces real production failures, and each one has a TypeScript 5+ enforcement mechanism that catches the violation before it ships.


1. The Anti-Pattern Graveyard: The 60-Line Payment Switch Statement

Here is the most common SOLID violation in payment services. It exists in nearly every codebase before the first architectural review:

TYPESCRIPT
// ❌ Anti-Pattern: Monolithic switch statement — violates SRP and OCP simultaneously

class PaymentProcessor {
  async processPayment(
    type: string,    // 'STRIPE' | 'PAYPAL' | 'APPLEPAY' | 'CRYPTO' | ...
    amount: number,
    currency: string,
  ): Promise<string> {
    if (type === 'STRIPE') {
      // 15 lines of Stripe-specific logic
      const stripeAmount = Math.round(amount * 100);
      const response = await fetch('https://api.stripe.com/v1/charges', {
        method: 'POST',
        headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` },
        body: new URLSearchParams({ amount: String(stripeAmount), currency }),
      });
      const data = await response.json();
      return data.id;
    } else if (type === 'PAYPAL') {
      // 20 lines of PayPal OAuth + order creation logic
      // ...
      return 'paypal_order_id';
    } else if (type === 'APPLEPAY') {
      // 12 lines of Apple Pay session handling
      // ...
      return 'applepay_token';
    } else if (type === 'CRYPTO') {
      // 18 lines of wallet address + blockchain confirmation logic
      // ...
      return 'tx_hash';
    }
    throw new Error(`Unsupported payment type: ${type}`);
  }
}

This function has four reasons to change — one for each payment provider. Adding a fifth provider requires modifying the existing function body, re-testing all five code paths, and deploying the entire class. Every new provider adds risk to every existing provider. This violates SRP (multiple responsibilities) and OCP (must modify to extend).


2. Single Responsibility Principle (SRP)

2.1 What "One Reason to Change" Actually Means

SRP is not about limiting functions to a single action. It is about cohesion around a business actor: a class should have one, and only one, business actor whose requirements can force a change to that class.

Robert Martin's definition: A module should be responsible to one, and only one, actor.

In the fintech domain, the actors are:

  • The Compliance Team cares about audit logging format and regulatory fields
  • The Finance Team cares about currency conversion and rounding rules
  • The Payments Team cares about gateway selection and retry logic
  • The Operations Team cares about error alerting and observability

A single PaymentProcessor class that logs, converts currencies, calls gateways, and sends alerts has four actors — meaning four teams whose independent requirements can force changes. That is a SRP violation.

2.2 Decomposing the God Class

TYPESCRIPT
// ✅ Each class has exactly one reason to change

// Owned by: Payments Team — gateway communication only
class StripeGateway implements IPaymentGateway {
  async charge(amount: number, currency: string): Promise<PaymentResult> {
    const stripeAmount = Math.round(amount * 100);
    const response = await fetch('https://api.stripe.com/v1/charges', {
      method: 'POST',
      headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` },
      body: new URLSearchParams({ amount: String(stripeAmount), currency }),
    });
    const data = await response.json();
    return { status: 'OK', transactionId: data.id };
  }
}

// Owned by: Finance Team — currency conversion rules only
class CurrencyNormalizer {
  normalize(amount: number, fromCurrency: string, toCurrency: string): number {
    if (fromCurrency === toCurrency) return amount;
    const rates: Record<string, number> = { USD: 1, EUR: 0.92, GBP: 0.79 };
    return (amount / rates[fromCurrency]) * rates[toCurrency];
  }
}

// Owned by: Compliance Team — audit format only
class PaymentAuditLogger {
  log(result: PaymentResult, actor: string): void {
    const entry = {
      timestamp: new Date().toISOString(),
      actor,
      transactionId: result.status === 'OK' ? result.transactionId : null,
      outcome: result.status,
    };
    console.log('[AUDIT]', JSON.stringify(entry));
  }
}

// Owned by: Payments Team — orchestration only; delegates everything
class PaymentOrchestrator {
  constructor(
    private readonly gateway: IPaymentGateway,
    private readonly normalizer: CurrencyNormalizer,
    private readonly logger: PaymentAuditLogger,
  ) {}

  async execute(amount: number, currency: string, actor: string): Promise<PaymentResult> {
    const normalized = this.normalizer.normalize(amount, currency, 'USD');
    const result = await this.gateway.charge(normalized, 'USD');
    this.logger.log(result, actor);
    return result;
  }
}

type PaymentResult = { status: 'OK'; transactionId: string } | { status: 'FAILED'; reason: string };
interface IPaymentGateway {
  charge(amount: number, currency: string): Promise<PaymentResult>;
}

Now changing the audit log format requires touching only PaymentAuditLogger. Adding a new currency requires touching only CurrencyNormalizer. Adding a new gateway requires touching only the new gateway class and the composition root. No other class changes.


3. Open/Closed Principle (OCP)

3.1 The Mechanical Statement

OCP: Software entities should be open for extension, but closed for modification.

In TypeScript 5+, OCP manifests through the strategy registry pattern using satisfies. A core engine is written once against an interface, and new behaviors are registered without modifying the engine.

3.2 Building a Type-Safe Strategy Registry with satisfies

TYPESCRIPT
// The shared gateway interface — the abstraction that enables OCP
interface IPaymentGateway {
  charge(amount: number, currency: string): Promise<PaymentResult>;
  refund(transactionId: string): Promise<void>;
}

// Strategy registry — satisfies ensures all SupportedGateways are covered
// and literal types are preserved for downstream type inference
type SupportedGateway = 'stripe' | 'paypal' | 'adyen';

const gatewayRegistry = {
  stripe: new StripeGateway(),
  paypal: new PayPalGateway(),
  adyen:  new AdyenGateway(),
} satisfies Record<SupportedGateway, IPaymentGateway>;

// Payment engine — closed for modification, open for extension
class PaymentEngine {
  private readonly registry: Record<SupportedGateway, IPaymentGateway>;

  constructor(registry: Record<SupportedGateway, IPaymentGateway>) {
    this.registry = registry;
  }

  async charge(
    gatewayKey: SupportedGateway,
    amount: number,
    currency: string,
  ): Promise<PaymentResult> {
    const gateway = this.registry[gatewayKey];
    if (!gateway) throw new Error(`Unknown gateway: ${gatewayKey}`);
    return gateway.charge(amount, currency);
  }
}

Adding a new gateway — zero modification to PaymentEngine:

TYPESCRIPT
// 1. Implement the interface
class CryptoGateway implements IPaymentGateway {
  async charge(amount: number, currency: string): Promise<PaymentResult> {
    return { status: 'OK', transactionId: `crypto_${Date.now()}` };
  }
  async refund(transactionId: string): Promise<void> { /* ... */ }
}

// 2. Extend the union type
type SupportedGateway = 'stripe' | 'paypal' | 'adyen' | 'crypto';

// 3. Add to the registry — satisfies enforces that all four are present
const gatewayRegistry = {
  stripe: new StripeGateway(),
  paypal: new PayPalGateway(),
  adyen:  new AdyenGateway(),
  crypto: new CryptoGateway(), // ← Added here only
} satisfies Record<SupportedGateway, IPaymentGateway>;
// If 'crypto' is absent: "Property 'crypto' is missing in type..."
Pro Tip & Optimization

The satisfies operator is OCP in type-system form: it validates that the registry covers all required gateway types (the constraint), while preserving the precise inferred types of each entry (the extension point). A type annotation would widen the types; satisfies checks them without widening.

3.3 The Law of Demeter

The Law of Demeter (LoD) — also called the Principle of Least Knowledge — states that a method should only call methods on:

  1. The object itself
  2. Its direct collaborators (passed via constructor or parameter)
  3. Objects it creates itself

Any deeper traversal creates tight coupling to the internal structure of collaborators.

TYPESCRIPT
// ❌ Train wreck — violates Law of Demeter
class OrderConfirmationService {
  async notifyCustomer(order: Order): Promise<void> {
    // Traverses order → customer → wallet → ledger → notificationPrefs
    const email = order.getCustomer().getWallet().getLedger().getOwner().email;
    await this.emailService.send(email, 'Your order is confirmed');
  }
}

// ✅ Tell, don't ask — the Order exposes what the service needs
class Order {
  getCustomerEmail(): string {
    return this.customer.contactEmail; // Internal traversal hidden inside the aggregate
  }
}

class OrderConfirmationService {
  async notifyCustomer(order: Order): Promise<void> {
    const email = order.getCustomerEmail(); // Only one level deep
    await this.emailService.send(email, 'Your order is confirmed');
  }
}

4. Liskov Substitution Principle (LSP)

4.1 What LSP Requires

LSP: Objects of a supertype should be replaceable with objects of a subtype without altering program correctness.

This has three mechanical requirements:

  1. Preconditions cannot be strengthened — a subtype's method cannot require more from the caller than the base type requires
  2. Postconditions cannot be weakened — a subtype's method cannot guarantee less to the caller than the base type guarantees
  3. Invariants must be preserved — the base class's invariants must hold in the subtype

The canonical LSP violation is the Square-Rectangle problem:

TYPESCRIPT
// ❌ LSP Violation: Square extends Rectangle — breaks behavioral contract

class Rectangle {
  protected width: number;
  protected height: number;

  constructor(width: number, height: number) {
    this.width = width;
    this.height = height;
  }

  setWidth(w: number): void { this.width = w; }
  setHeight(h: number): void { this.height = h; }

  area(): number { return this.width * this.height; }
}

class Square extends Rectangle {
  constructor(side: number) {
    super(side, side);
  }

  // ❌ Strengthens the invariant: width and height must always be equal
  // This BREAKS any code written against Rectangle's contract
  override setWidth(w: number): void {
    this.width = w;
    this.height = w; // Side effect: changes height too
  }

  override setHeight(h: number): void {
    this.height = h;
    this.width = h; // Side effect: changes width too
  }
}

// Code written against Rectangle — correct behavior expected:
function testRectangle(rect: Rectangle): void {
  rect.setWidth(5);
  rect.setHeight(3);
  console.assert(rect.area() === 15, 'Area must be 5 × 3 = 15');
}

testRectangle(new Rectangle(1, 1)); // ✅ Passes: area = 15
testRectangle(new Square(1));       // ❌ Fails: area = 9 (width setHeight also set to 3)
// Square is not substitutable for Rectangle — LSP violated

4.2 TypeScript --strictFunctionTypes and Variance

TypeScript 2.6 introduced --strictFunctionTypes (enabled by --strict), which enforces function parameter contravariance — a mechanical LSP check at the type level.

TYPESCRIPT
// Under --strictFunctionTypes:
// Parameter types must be CONTRAVARIANT (accept wider or equal types)
// Return types must be COVARIANT (return narrower or equal types)

type ProcessPayment = (event: PaymentEvent) => void;

// ✅ Contravariant parameter: accepts a wider type (BaseEvent is wider than PaymentEvent)
const contravariantHandler: ProcessPayment = (event: BaseEvent) => {
  console.log(event.eventId); // OK — BaseEvent has eventId
};

// ❌ Covariant parameter (TypeScript error under --strictFunctionTypes):
const covariantHandler: ProcessPayment = (event: StripePaymentEvent) => {
  // StripePaymentEvent is narrower — accessing .stripeCustomerId may not exist on PaymentEvent
  console.log(event.stripeCustomerId); // TS Error: Argument of type 'StripePaymentEvent' is not assignable
};
Two-column diagram. LEFT column labeled 'OCP Violation — Fragile Switch' in red: shows PaymentProcessor class with a large switch statement body listing 'case STRIPE', 'case PAYPAL', 'case APPLEPAY', 'case CRYPTO'. Red arrows point to the switch: 'Add CryptoPay → modify switch', 'Add ApplePay → modify switch'. Red callout: 'Every new provider adds regression risk to all existing providers'. RIGHT column labeled 'OCP via Strategy Registry' in cyan: shows PaymentEngine class with a small 'registry[gatewayKey].charge()' method body. Below it, a registry map showing 'stripe → StripeGateway', 'paypal → PayPalGateway', 'adyen → AdyenGateway'. A new 'crypto → CryptoGateway' row added with a green '+' icon. Cyan callout: 'PaymentEngine never modified — extension is additive only'.
Two-column diagram. LEFT column labeled 'OCP Violation — Fragile Switch' in red: shows PaymentProcessor class with a large switch statement body listing 'cas…

5. Design by Contract: Pre/Postconditions & Invariants

TypeScript does not have native @requires / @ensures annotations, but the TypeScript compiler and runtime patterns can enforce the three DbC rules mechanically.

5.1 Preconditions: Never Strengthen in Subtypes

TYPESCRIPT
interface IDiscountCalculator {
  // Precondition: amount > 0, discountRate ∈ [0, 1]
  calculate(amount: number, discountRate: number): number;
}

class StandardDiscount implements IDiscountCalculator {
  calculate(amount: number, discountRate: number): number {
    // Checks base preconditions
    if (amount <= 0) throw new RangeError('Amount must be positive');
    if (discountRate < 0 || discountRate > 1) throw new RangeError('Rate must be 0–1');
    return amount * (1 - discountRate);
  }
}

// ❌ Subtype strengthens precondition: rejects amount < 100
class BulkDiscount extends StandardDiscount {
  override calculate(amount: number, discountRate: number): number {
    // WRONG: requires amount >= 100 — caller written against IDiscountCalculator cannot know this
    if (amount < 100) throw new RangeError('BulkDiscount requires amount >= 100');
    return super.calculate(amount, discountRate);
  }
}

// Caller uses IDiscountCalculator — cannot distinguish BulkDiscount:
const calculator: IDiscountCalculator = new BulkDiscount();
calculator.calculate(50, 0.1); // ❌ Throws — but caller had no way to know

5.2 Postconditions: Never Weaken in Subtypes

TYPESCRIPT
interface IPaymentResult {
  // Postcondition: always returns a non-empty transactionId on success
  processPayment(amount: number): { transactionId: string; status: 'OK' };
}

// ❌ Subtype weakens postcondition: may return empty transactionId
class DeferredProcessor implements IPaymentResult {
  processPayment(amount: number): { transactionId: string; status: 'OK' } {
    return { transactionId: '', status: 'OK' }; // Empty string violates postcondition
  }
}
Crucial Requirement

Cross-Language Rosetta — OCP Strategy Pattern:

  • Go: map[string]PaymentStrategy where PaymentStrategy is an interface. Add a new strategy by inserting into the map — the engine function never changes.
  • Python: dict[str, Callable] or dict[str, Protocol] dispatch table. Same extension mechanism.
  • TypeScript 5+: satisfies Record<SupportedGateway, IPaymentGateway> — adds compile-time exhaustiveness on top of the dispatch table pattern.

Summary

Principle Mechanical Expression TypeScript 5+ Enforcement
SRP One reason to change = one business actor Decompose god classes into focused collaborators
OCP Extend without modifying core Strategy registries with satisfies Record<K, I>
Law of Demeter Talk to immediate collaborators only Count dots — more than 2 is a smell
LSP Subtypes must be substitutable --strictFunctionTypes catches variance violations
Precondition rule Subtypes cannot strengthen input requirements Validate at the base class level, not in subtypes
Postcondition rule Subtypes cannot weaken output guarantees Return types must be equal or narrower in subtypes

What's Next

Part 7 completes the SOLID picture with the two decoupling principles. Interface Segregation slices fat monolithic interfaces into narrow role contracts. The Dependency Inversion Principle enforces that high-level domain policy never imports from low-level infrastructure. Part 7: SOLID Principles: Decoupling, Inversion & Modern IoC covers the Service Locator anti-pattern, manual DI vs IoC containers, and TypeScript 5.0+ Stage 3 Decorators.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#SOLID#SRP#OCP#LSP#Design Principles
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.