Aggregates, invariants, Ports & Adapters, and CQRS applied to banking ledgers — keeping domain logic clean under extreme architectural complexity.
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 & RegisterDiscussion
0Join the discussion