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.
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:
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
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
Adding a new gateway — zero modification to PaymentEngine:
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:
- The object itself
- Its direct collaborators (passed via constructor or parameter)
- Objects it creates itself
Any deeper traversal creates tight coupling to the internal structure of collaborators.
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:
- Preconditions cannot be strengthened — a subtype's method cannot require more from the caller than the base type requires
- Postconditions cannot be weakened — a subtype's method cannot guarantee less to the caller than the base type guarantees
- Invariants must be preserved — the base class's invariants must hold in the subtype
The canonical LSP violation is the Square-Rectangle problem:
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.
![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'.](https://pub-74778554195b4df89d82f0d61988355a.r2.dev/assets/blog/system-design/typescript-solid-principles-srp-ocp-lsp/fig-01.png)
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
5.2 Postconditions: Never Weaken in Subtypes
Cross-Language Rosetta — OCP Strategy Pattern:
- Go:
map[string]PaymentStrategywherePaymentStrategyis an interface. Add a new strategy by inserting into the map — the engine function never changes. - Python:
dict[str, Callable]ordict[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.
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.