Siddhant Deval
Siddhant Deval
system design25 min read

Production Architecture, Concurrency & End-to-End LLD Case Study

Senior engineers prove architectural mastery by synthesizing SOLID principles, domain aggregates, and concurrency control into a production-grade engine under interview constraints. This article delivers a complete 60-minute machine coding framework, async mutex patterns for double-spend prevention, and an end-to-end Enterprise Multi-Channel Payment & Ledger Engine case study.

Series·Part 12 of 13

TypeScript Low-Level Design & Object-Oriented Architecture

Production Architecture, Concurrency & End-to-End LLD Case Study

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.

Architectural Note

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.


1. The Anti-Pattern Graveyard: The Double-Spend Async Interleave

Here is the race condition that drains accounts in production payment systems every day:

TYPESCRIPT
// ❌ Anti-Pattern: Concurrent transfer requests with no lock — double-spend vulnerability

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')
    }

    // ⚠️ Race window: two simultaneous calls both pass the balance check
    // before either writes the debit. Both see balance = 1000.
    // Both transfer 800. Final balance: -600.
    await this.persist('debit', fromId, amount)  // await = yield event loop
    await this.persist('credit', toId, amount)
  }
}

// Simulated concurrent requests — both pass the balance check simultaneously
const ledger = new LedgerService()
await Promise.all([
  ledger.transfer('acc-1', 'acc-2', 800),
  ledger.transfer('acc-1', 'acc-3', 800),
])
// Result: acc-1 balance = -600. acc-2 and acc-3 each credited 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.


2. Domain Foundation: Rich Models Over Anemic Data Bags

2.1 Branded Primitives

TYPESCRIPT
// Nominal types prevent silent ID swaps — UserId ≠ AccountId at the type level
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'>       // Always in cents — no float arithmetic

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
}

2.2 Result Type — No Exceptions in Domain Logic

TYPESCRIPT
// Functional error handling: failures are values, not control flow
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 })

2.3 The LedgerAccount Aggregate

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


3. The Async Mutex: Serializing Concurrent Account Operations

TYPESCRIPT
// AsyncMutex: per-account lock that serializes concurrent async operations
// Uses a promise chain — zero dependencies, pure Node.js
class AsyncMutex {
  private queue: Promise<void> = Promise.resolve()

  async acquire<T>(fn: () => Promise<T>): Promise<T> {
    // Append this work to the end of the current queue.
    // Each waiter chains off the previous lock release.
    const result = this.queue.then(fn)
    // Advance the queue past this work item (swallowing errors so the chain continues)
    this.queue = result.then(
      () => undefined,
      () => undefined,
    )
    return result
  }
}

// ✅ Fixed LedgerService with per-account mutex
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 }>> {
    // Lock both accounts in a canonical order to prevent deadlock
    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) {
          // Compensate: re-credit the source (saga compensation)
          from.credit(amount, `Reversal — credit failed for ${toId}`)
          return err(creditResult.error)
        }

        return ok({ debitTx: debitResult.value, creditTx: creditResult.value })
      }),
    )
  }
}
Pro Tip & Optimization

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.


4. Payment Strategy Registry: Runtime Dispatch Without Type Switching

TYPESCRIPT
// Port interface — high-level policy depends on this abstraction, not concrete SDKs
interface PaymentGateway {
  charge(amount: Money, currency: string, token: string): Promise<Result<string>>
  refund(chargeId: string, amount: Money): Promise<Result<void>>
}

// Strategy registry — replaces if/else chains with a keyed registry
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}"`)
  }
}

// Adapter — wraps the Stripe SDK behind the PaymentGateway port
class StripeAdapter implements PaymentGateway {
  constructor(private readonly apiKey: string) {}

  async charge(amount: Money, currency: string, token: string): Promise<Result<string>> {
    try {
      // In production: const charge = await stripe.charges.create(...)
      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 {
      // In production: await stripe.refunds.create({ charge: chargeId, amount })
      return ok(undefined)
    } catch (e) {
      return err(`Stripe refund failed: ${(e as Error).message}`)
    }
  }
}

5. Domain Event Bus: Decoupled Notifications

TYPESCRIPT
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)))
  }
}

// Usage: observers subscribe without coupling to the payment service
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 }) => {
  // Fraud detection — fires concurrently with audit via Promise.allSettled
  if (amount > toMoney(1_000_000)) {
    console.warn(`[Fraud] Large transfer flagged: ${amount} cents on ${accountId}`)
  }
})

6. In-Memory Repository Harness

TYPESCRIPT
interface LedgerRepository {
  findById(id: AccountId): Promise<LedgerAccount | null>
  save(account: LedgerAccount): Promise<void>
  findByOwner(ownerId: UserId): Promise<LedgerAccount[]>
}

// Interview-ready: Map-backed, async-delayed to simulate real I/O
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)
  }
}

7. The 60-Minute LLD Machine Coding Framework

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

8. End-to-End Case Study: Enterprise Multi-Channel Payment Engine

TYPESCRIPT
// Assemble the full production system
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_***'))

  // Wire domain events
  bus.on<{ from: AccountId; to: AccountId; amount: Money }>('transfer.completed', async (e) => {
    console.log(`[Audit] Transfer ${e.amount}¢: ${e.from} → ${e.to}`)
  })

  // Seed accounts
  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)))

  // Concurrent transfer stress test — proves mutex works
  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`)
  // Expected: 10 succeed (acc1 had 100k, 10 × 5k = 50k)
  // acc1 final balance: 50_000 (= 100_000 - 10 × 5_000)
}

Summary

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

Series Complete

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.

Research & Synthesis Note

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

#TypeScript#Concurrency#Production Architecture#LLD Interview#Async Mutex#Machine Coding
Siddhant Deval

Written by Siddhant Deval

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