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. This final article synthesizes all eleven preceding parts into a single, battle-tested framework for building production-grade systems — and for demonstrating that mastery under the pressure of a 60-minute machine coding interview.
This is Part 12 of 12 in the TypeScript Low-Level Design & Object-Oriented Architecture series. It requires Parts 1–11. The unified domain throughout this series is an Enterprise Fintech Ledger & Order Fulfillment Platform.
Here is the race condition that drains accounts in production payment systems every day:
class LedgerService {
private accounts = new Map<string, number>()
async transfer(fromId: string, toId: string, amount: number): Promise<void> {
const balance = this.accounts.get(fromId) ?? 0
if (balance < amount) {
throw new Error('Insufficient funds')
}
await this.persist('debit', fromId, amount)
await this.persist('credit', toId, amount)
}
}
const ledger = new LedgerService()
await Promise.all([
ledger.transfer('acc-1', 'acc-2', 800),
ledger.transfer('acc-1', 'acc-3', 800),
])
This is not a bug in JavaScript's concurrency model — it is correct behavior exploited incorrectly. Node.js is single-threaded, but await yields the event loop, allowing the second transfer to interleave between the balance check and the debit write. The fix is not threads — it is an explicit serialization mechanism scoped to the account.
declare const __brand: unique symbol
type Brand<T, B> = T & { [__brand]: B }
type UserId = Brand<string, 'UserId'>
type AccountId = Brand<string, 'AccountId'>
type Money = Brand<number, 'Money'>
const toMoney = (cents: number): Money => {
if (!Number.isInteger(cents) || cents < 0) throw new Error('Money must be non-negative integer cents')
return cents as Money
}
type Ok<T> = { ok: true; value: T }
type Err<E> = { ok: false; error: E }
type Result<T, E = string> = Ok<T> | Err<E>
const ok = <T>(value: T): Ok<T> => ({ ok: true, value })
const err = <E>(error: E): Err<E> => ({ ok: false, error })
interface Transaction {
id: string
type: 'credit' | 'debit'
amount: Money
timestamp: Date
description: string
}
class LedgerAccount {
private _balance: Money
private _transactions: Transaction[] = []
constructor(
private readonly id: AccountId,
private readonly ownerId: UserId,
initialBalance: Money,
) {
this._balance = initialBalance
}
get balance(): Money { return this._balance }
get id(): AccountId { return this.id }
credit(amount: Money, description: string): Result<Transaction> {
if (amount <= 0) return err('Credit amount must be positive')
const tx: Transaction = {
id: `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`,
type: 'credit',
amount,
timestamp: new Date(),
description,
}
this._balance = toMoney(this._balance + amount)
this._transactions.push(tx)
return ok(tx)
}
debit(amount: Money, description: string): Result<Transaction> {
if (amount <= 0) return err('Debit amount must be positive')
if (amount > this._balance) return err(`Insufficient funds: balance ${this._balance}, requested ${amount}`)
const tx: Transaction = {
id: `tx_${Date.now()}_${Math.random().toString(36).slice(2)}`,
type: 'debit',
amount,
timestamp: new Date(),
description,
}
this._balance = toMoney(this._balance - amount)
this._transactions.push(tx)
return ok(tx)
}
getHistory(): readonly Transaction[] {
return this._transactions
}
}
The aggregate owns its own invariants: it is impossible to overdraft through a public API. No service layer can accidentally bypass the balance check.
class AsyncMutex {
private queue: Promise<void> = Promise.resolve()
async acquire<T>(fn: () => Promise<T>): Promise<T> {
const result = this.queue.then(fn)
this.queue = result.then(
() => undefined,
() => undefined,
)
return result
}
}
class SafeLedgerService {
private accounts = new Map<AccountId, LedgerAccount>()
private mutexes = new Map<AccountId, AsyncMutex>()
private getMutex(id: AccountId): AsyncMutex {
if (!this.mutexes.has(id)) this.mutexes.set(id, new AsyncMutex())
return this.mutexes.get(id)!
}
async transfer(
fromId: AccountId,
toId: AccountId,
amount: Money,
): Promise<Result<{ debitTx: Transaction; creditTx: Transaction }>> {
const [first, second] = [fromId, toId].sort() as [AccountId, AccountId]
return this.getMutex(first).acquire(() =>
this.getMutex(second).acquire(async () => {
const from = this.accounts.get(fromId)
const to = this.accounts.get(toId)
if (!from) return err(`Account ${fromId} not found`)
if (!to) return err(`Account ${toId} not found`)
const debitResult = from.debit(amount, `Transfer to ${toId}`)
if (!debitResult.ok) return err(debitResult.error)
const creditResult = to.credit(amount, `Transfer from ${fromId}`)
if (!creditResult.ok) {
from.credit(amount, `Reversal — credit failed for ${toId}`)
return err(creditResult.error)
}
return ok({ debitTx: debitResult.value, creditTx: creditResult.value })
}),
)
}
}
Canonical lock ordering ([fromId, toId].sort()) prevents deadlock when two concurrent transfers cross each other: A→B and B→A both lock A first, so neither can form a cycle.
interface PaymentGateway {
charge(amount: Money, currency: string, token: string): Promise<Result<string>>
refund(chargeId: string, amount: Money): Promise<Result<void>>
}
class PaymentStrategyRegistry {
private readonly gateways = new Map<string, PaymentGateway>()
register(name: string, gateway: PaymentGateway): this {
this.gateways.set(name, gateway)
return this
}
resolve(name: string): Result<PaymentGateway> {
const gw = this.gateways.get(name)
return gw ? ok(gw) : err(`Unknown payment gateway: "${name}"`)
}
}
class StripeAdapter implements PaymentGateway {
constructor(private readonly apiKey: string) {}
async charge(amount: Money, currency: string, token: string): Promise<Result<string>> {
try {
const chargeId = `ch_${Date.now()}`
return ok(chargeId)
} catch (e) {
return err(`Stripe charge failed: ${(e as Error).message}`)
}
}
async refund(chargeId: string, amount: Money): Promise<Result<void>> {
try {
return ok(undefined)
} catch (e) {
return err(`Stripe refund failed: ${(e as Error).message}`)
}
}
}
type EventHandler<T> = (payload: T) => Promise<void> | void
class DomainEventBus {
private handlers = new Map<string, EventHandler<unknown>[]>()
on<T>(event: string, handler: EventHandler<T>): void {
const existing = this.handlers.get(event) ?? []
this.handlers.set(event, [...existing, handler as EventHandler<unknown>])
}
async emit<T>(event: string, payload: T): Promise<void> {
const handlers = this.handlers.get(event) ?? []
await Promise.allSettled(handlers.map(h => h(payload)))
}
}
const bus = new DomainEventBus()
bus.on<{ accountId: AccountId; amount: Money }>('transfer.completed', async ({ accountId, amount }) => {
console.log(`[Audit] Account ${accountId} transferred ${amount} cents`)
})
bus.on<{ accountId: AccountId; amount: Money }>('transfer.completed', async ({ accountId, amount }) => {
if (amount > toMoney(1_000_000)) {
console.warn(`[Fraud] Large transfer flagged: ${amount} cents on ${accountId}`)
}
})
interface LedgerRepository {
findById(id: AccountId): Promise<LedgerAccount | null>
save(account: LedgerAccount): Promise<void>
findByOwner(ownerId: UserId): Promise<LedgerAccount[]>
}
class InMemoryLedgerRepository implements LedgerRepository {
private store = new Map<AccountId, LedgerAccount>()
private delay(ms = 10): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async findById(id: AccountId): Promise<LedgerAccount | null> {
await this.delay()
return this.store.get(id) ?? null
}
async save(account: LedgerAccount): Promise<void> {
await this.delay()
this.store.set(account.id, account)
}
async findByOwner(ownerId: UserId): Promise<LedgerAccount[]> {
await this.delay()
return [...this.store.values()].filter(a => a.ownerId === ownerId)
}
}
Minutes 0–10 → Requirements & API Contracts
Minutes 10–20 → Domain Model & UML Sketch (Mermaid.js)
Minutes 20–45 → Core Implementation (aggregates, ports, adapters)
Minutes 45–55 → Concurrency & Edge Cases (mutex, compensation)
Minutes 55–60 → Tests: verify invariants and race conditions
async function buildProductionEngine() {
const repo = new InMemoryLedgerRepository()
const ledger = new SafeLedgerService()
const bus = new DomainEventBus()
const registry = new PaymentStrategyRegistry()
.register('stripe', new StripeAdapter(process.env.STRIPE_KEY ?? 'sk_test_***'))
bus.on<{ from: AccountId; to: AccountId; amount: Money }>('transfer.completed', async (e) => {
console.log(`[Audit] Transfer ${e.amount}¢: ${e.from} → ${e.to}`)
})
const acc1 = 'acc_001' as AccountId
const acc2 = 'acc_002' as AccountId
ledger.accounts.set(acc1, new LedgerAccount(acc1, 'usr_1' as UserId, toMoney(100_000)))
ledger.accounts.set(acc2, new LedgerAccount(acc2, 'usr_2' as UserId, toMoney(50_000)))
const transfers = Array.from({ length: 10 }, (_, i) =>
ledger.transfer(acc1, acc2, toMoney(5_000))
)
const results = await Promise.all(transfers)
const succeeded = results.filter(r => r.ok).length
const failed = results.filter(r => !r.ok).length
console.log(`Transfers: ${succeeded} succeeded, ${failed} failed`)
}
- Avoid the over-engineering trap: start with the simplest construct that satisfies domain requirements and refactor toward patterns only when concrete variance emerges.
- Favor rich domain models over anemic data bags: entities must enforce their own business invariants and protect internal state from arbitrary mutation.
- Combine OOP domain boundaries with functional error handling (
Result<T, E>): explicit failure paths in return types make edge cases visible to the compiler.
- Node.js event-loop concurrency requires explicit locking mechanisms: asynchronous operations with multiple
await steps are vulnerable to race conditions without mutexes.
- In LLD interviews, prioritize working domain invariants and clean interface contracts over exhaustive pattern decoration.
This is the final article in the TypeScript Low-Level Design & Object-Oriented Architecture series. The series covered class internals, nominal modeling, contracts, SOLID principles, GoF patterns, and production concurrency — all unified through a single fintech domain. You now have the complete mental model, vocabulary, and implementation toolkit to design and explain any production-grade TypeScript system with precision.