Module A-12·23 min read

Aggregates, invariants, Ports & Adapters, and CQRS applied to banking ledgers — keeping domain logic clean under extreme architectural complexity.

Written by Jatin Jain Saraf · Architect of SupraScan

Module 11 — Enterprise Architecture for State-Heavy Systems: DDD & Clean Architecture

What this module covers: A blockchain indexer processing 50,000 events/second will accumulate complex business rules over time: transaction validity invariants, account balance constraints, settlement reconciliation logic, fraud detection rules. Without architectural discipline, these rules scatter across route handlers, database queries, and event listeners — untestable, fragile, and impossible to reason about under load. This module covers Domain-Driven Design applied to state-heavy systems, Clean Architecture for transport-agnostic domain logic, and CQRS for systems where the read model and write model need to evolve independently.


Why Architecture Matters More at Scale

At low throughput (< 1K events/sec), architectural shortcuts are invisible. Validation in route handlers, business logic in SQL queries, domain concepts scattered across layers — these are maintainability problems, not performance problems.

At high throughput, architectural shortcuts become performance problems:

// BAD: business logic in the route handler
app.post('/api/v2/payments', async (req, res) => {
  const { senderId, recipientId, amount } = req.body;
 
  // Business logic in HTTP handler:
  const sender = await db.query('SELECT balance FROM accounts WHERE id = $1', [senderId]);
  if (!sender.rows[0]) return res.status(404).json({ error: 'Sender not found' });
  if (sender.rows[0].balance < amount) return res.status(400).json({ error: 'Insufficient funds' });
 
  // More business logic:
  const fraud = await checkFraud(senderId, amount);
  if (fraud.risk > 0.8) return res.status(403).json({ error: 'High risk transaction' });
 
  // More business logic:
  await db.query('BEGIN');
  await db.query('UPDATE accounts SET balance = balance - $1 WHERE id = $2', [amount, senderId]);
  await db.query('UPDATE accounts SET balance = balance + $1 WHERE id = $2', [amount, recipientId]);
  await db.query('INSERT INTO transactions VALUES ...', [...]);
  await db.query('COMMIT');
 
  res.json({ status: 'accepted' });
});

Problems:

  • Untestable: you need a running HTTP server and database to test the business logic
  • Uncacheable: the business rules are coupled to the SQL and HTTP transport
  • Unreusable: the same logic cannot be reused by a gRPC endpoint or a Kafka consumer
  • Unrefactorable: changing the validation requires touching the route handler

Domain-Driven Design Core Concepts

DDD provides vocabulary and patterns for modeling complex business domains in code. For a payment system, the key concepts are:

Entities

Objects with identity that persists over time. An account is an entity — it has an ID, and its state changes.

// modules/accounts/domain/Account.ts
export class Account {
  #id: string;
  #balance: bigint;
  #status: 'active' | 'frozen' | 'closed';
  #version: number;  // optimistic concurrency token — see the repository below
  #events: DomainEvent[] = [];
 
  private constructor(id: string, balance: bigint, status: 'active' | 'frozen' | 'closed', version: number) {
    this.#id = id;
    this.#balance = balance;
    this.#status = status;
    this.#version = version;
  }
 
  static create(id: string, initialBalance: bigint): Account {
    const account = new Account(id, initialBalance, 'active', 0);
    account.#events.push(new AccountCreatedEvent(id, initialBalance));
    return account;
  }
 
  static reconstitute(id: string, balance: bigint, status: 'active' | 'frozen' | 'closed', version: number): Account {
    return new Account(id, balance, status, version);
  }
 
  get version() { return this.#version; }
 
  debit(amount: bigint, reference: string): void {
    // Invariant enforcement — IN the domain object, not in application code
    if (this.#status !== 'active') {
      throw new AccountNotActiveError(this.#id, this.#status);
    }
    if (amount <= 0n) {
      throw new InvalidAmountError('Debit amount must be positive');
    }
    if (this.#balance < amount) {
      throw new InsufficientFundsError(this.#id, this.#balance, amount);
    }
 
    this.#balance -= amount;
    this.#events.push(new AccountDebitedEvent(this.#id, amount, reference));
  }
 
  credit(amount: bigint, reference: string): void {
    if (this.#status === 'closed') {
      throw new AccountClosedError(this.#id);
    }
    if (amount <= 0n) throw new InvalidAmountError('Credit amount must be positive');
 
    this.#balance += amount;
    this.#events.push(new AccountCreditedEvent(this.#id, amount, reference));
  }
 
  freeze(): void {
    if (this.#status !== 'active') throw new AccountNotActiveError(this.#id, this.#status);
    this.#status = 'frozen';
    this.#events.push(new AccountFrozenEvent(this.#id));
  }
 
  get id() { return this.#id; }
  get balance() { return this.#balance; }
  get status() { return this.#status; }
  get events() { return [...this.#events]; }
  clearEvents() { this.#events = []; }
}

Why this matters for high-throughput systems:

The invariants are in the domain object and enforced before any I/O happens. At 50K payments/second, you want to reject invalid payments with zero database roundtrips. The Account.debit() method checks balance without touching the database — if it throws, the payment is rejected before any SQL runs.

Value Objects

Immutable objects without identity. An Amount is a value object — two amounts of 100 USD are identical regardless of which "instance" they are.

export class Money {
  readonly amount: bigint;
  readonly currency: string;
 
  constructor(amount: bigint, currency: string) {
    if (amount < 0n) throw new Error('Money cannot be negative');
    if (!['USD', 'INR', 'USDC', 'ETH'].includes(currency)) {
      throw new Error(`Unsupported currency: ${currency}`);
    }
    this.amount = amount;
    this.currency = currency;
  }
 
  add(other: Money): Money {
    if (other.currency !== this.currency) throw new Error('Currency mismatch');
    return new Money(this.amount + other.amount, this.currency);
  }
 
  subtract(other: Money): Money {
    if (other.currency !== this.currency) throw new Error('Currency mismatch');
    if (other.amount > this.amount) throw new InsufficientFundsError();
    return new Money(this.amount - other.amount, this.currency);
  }
 
  equals(other: Money): boolean {
    return this.amount === other.amount && this.currency === other.currency;
  }
}

Aggregates: The Consistency Boundary

An aggregate is a cluster of entities and value objects that must be changed together atomically. The aggregate root is the entry point — external code can only access the aggregate through the root.

An aggregate is the blast radius of one transaction — everything inside it goes up together or not at all; everything outside only ever hears about it afterward, through a message slipped under the door (a domain event). That last part matters: the standard DDD invariant is that a single transaction should modify exactly one aggregate. Two aggregates never get mutated in the same in-process call — the second one finds out via an event, on its own transaction, after the first has already committed.

The naive version of a Payment breaks this rule directly — it reaches into two separate account aggregates from inside a single method call:

// WRONG: Payment.process() mutates two aggregate roots in one call.
// sender and recipient are separate Account aggregates — this couples
// their consistency boundaries and, done naively against a real database,
// requires locking both rows in a single transaction (see below).
process(): void {
  if (this.#status !== 'pending') throw new InvalidStateTransitionError('process', this.#status);
  this.#sender.debit(this.#amount.amount, this.#id);      // aggregate #1
  this.#recipient.credit(this.#amount.amount, this.#id);  // aggregate #2 — should not happen here
  this.#status = 'completed';
  this.#events.push(new PaymentCompletedEvent(this.#id));
}

The fix — one aggregate per transaction, coordinated by a domain event: Payment.process() (or, better, the application service driving it) modifies exactly one side — the sender's debit — as its own transaction, and emits MoneyDebited. A process manager (a saga) reacts to that event and issues the credit to the recipient as a separate transaction. If the credit fails, the process manager issues a compensating action (reversing the debit) rather than the whole operation silently rolling back two aggregates it never should have touched together in the first place.

// The Payment aggregate now only drives ONE aggregate's mutation directly.
export class Payment {
  #id: string;
  #senderId: string;
  #recipientId: string;
  #amount: Money;
  #status: 'pending' | 'processing' | 'completed' | 'failed';
  #events: DomainEvent[] = [];
 
  static initiate(sender: Account, recipient: Account, amount: Money): Payment {
    if (sender.id === recipient.id) throw new SelfTransferError();
    if (sender.status !== 'active') throw new AccountNotActiveError(sender.id, sender.status);
    if (sender.balance < amount.amount) throw new InsufficientFundsError(sender.id, sender.balance, amount.amount);
 
    const payment = new Payment(generateId(), sender.id, recipient.id, amount, 'pending');
    payment.#events.push(new PaymentInitiatedEvent(payment.#id, sender.id, recipient.id, amount));
    return payment;
  }
 
  // Modifies ONLY the sender aggregate. The recipient is updated later,
  // by a separate transaction, in reaction to the MoneyDebited event below.
  debitSender(sender: Account): void {
    if (this.#status !== 'pending') throw new InvalidStateTransitionError('debitSender', this.#status);
    sender.debit(this.#amount.amount, this.#id);
    this.#status = 'processing';
    this.#events.push(new MoneyDebitedEvent(this.#id, this.#senderId, this.#recipientId, this.#amount));
  }
 
  complete(): void {
    this.#status = 'completed';
    this.#events.push(new PaymentCompletedEvent(this.#id));
  }
 
  fail(reason: string): void {
    this.#status = 'failed';
    this.#events.push(new PaymentFailedEvent(this.#id, reason));
  }
}
// Process manager (saga): reacts to MoneyDebited in its OWN transaction,
// crediting the recipient aggregate separately from the sender's debit.
class PaymentSagaHandler {
  constructor(private accounts: AccountRepository, private events: DomainEventPublisher) {}
 
  // Triggered by MoneyDebited — runs as an independent transaction against
  // the recipient aggregate only. Never touches the sender in this step.
  async onMoneyDebited(event: MoneyDebitedEvent): Promise<void> {
    try {
      const recipient = await this.accounts.findById(event.recipientId);
      recipient.credit(event.amount.amount, event.paymentId);
      await this.accounts.save(recipient);
      await this.events.publish(new MoneyCreditedEvent(event.paymentId, event.recipientId, event.amount));
    } catch (err) {
      // Compensating action: reverse the sender's debit in its own
      // transaction, rather than pretending the two aggregates ever
      // shared a rollback boundary.
      await this.events.publish(new PaymentCompensationRequiredEvent(event.paymentId, event.senderId, event.amount));
    }
  }
 
  async onPaymentCompensationRequired(event: PaymentCompensationRequiredEvent): Promise<void> {
    const sender = await this.accounts.findById(event.senderId);
    sender.credit(event.amount.amount, `compensation:${event.paymentId}`);  // reverse the debit
    await this.accounts.save(sender);
  }
}

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.