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:
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.
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.
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.
For a UPI payment:
Clean Architecture: The Dependency Rule
Clean Architecture enforces that dependencies only point inward — from infrastructure to application to domain. The domain never knows about HTTP, Kafka, or PostgreSQL.
Ports and Adapters (Hexagonal Architecture)
The transport-agnostic domain: the same ProcessPaymentUseCase works whether triggered by:
The domain and application layers are untouched. Only the infrastructure adapter changes.
Dependency Injection: Wiring the Layers
Swap PostgreSQL for an in-memory repository for unit tests without touching any business logic:
Tests run in milliseconds. No database. No network. Business logic tested in isolation.
CQRS: Separate Write and Read Models
Command Query Responsibility Segregation (CQRS) separates the model that handles writes (commands) from the model that handles reads (queries).
For a payment ledger:
- Write model: normalized, ACID-consistent. Enforces invariants. Optimized for integrity.
- Read model: denormalized, eventually consistent. Pre-computed aggregations. Optimized for query speed.
The read model is updated by an event consumer that processes domain events from Kafka:
CQRS benefits for high-throughput systems:
- Write and read databases can be scaled independently
- Read queries never compete with write transactions
- The read model can be optimized for specific query patterns (denormalized, indexed differently)
- If the read model becomes stale, you can rebuild it by replaying events from Kafka
Anti-Corruption Layer for Blockchain RPC
When your indexer calls a third-party blockchain RPC node (Ethereum's eth_getBlockByNumber, Supra's block API), the response structure is the external system's format. Letting that format leak into your domain creates coupling — if the RPC response format changes, your domain objects break.
The domain Block object uses your types (Buffer for hashes, BigInt for amounts). If Ethereum changes their API response format, you update translateEthBlock — nowhere else.
Production Incident: Domain Logic in a SQL Query
Context: A banking ledger service. Balance calculations were done in SQL via a stored procedure called by the application:
What happened:
A regulatory requirement changed: transactions pending for more than 48 hours must be included in the balance calculation as "provisional debits." The business rule changed from:
Balance = sum of settled transactions
To:
Balance = sum of settled transactions + sum of pending debits older than 48 hours
The stored procedure was updated. But the update missed that pending credits older than 48 hours should also be included (oversight). The bug was in production for 11 days before a reconciliation audit caught it. 847 accounts had incorrect balance calculations.
Root cause: the business rule was in SQL, not in domain code. SQL is not testable with unit tests. The domain rule was invisible to developers who tested in isolation.
The fix — move the rule to the domain:
The test caught the exact bug — credits and debits treated consistently — before any code reached production.
Summary
| Concept | Key Takeaway |
|---|---|
| Entity | Object with identity that changes over time. Enforces its own invariants. |
| Value Object | Immutable, identity-less. Money(5000, 'INR') is always equal to another Money(5000, 'INR'). |
| Aggregate | Cluster of entities with one root. Changed atomically. Emits domain events. |
| Invariant | Rule the domain enforces. Never in SQL. Always in domain code. Testable without I/O. |
| Clean Architecture | Domain → Application → Infrastructure. Dependencies flow inward only. |
| Ports and Adapters | Port = interface the domain defines. Adapter = infrastructure implementation. Swappable. |
| DI container | Wire adapters to ports at startup. Swap for test doubles in tests. |
| CQRS | Write model: normalized, strict, ACID. Read model: denormalized, eventual, fast. |
| Anti-corruption layer | Translate external formats at the boundary. External changes never reach domain objects. |
| Domain events | Emitted by aggregates after state changes. Published to Kafka by the infrastructure layer. |
The architecture is clean. Module 12 covers how to keep it healthy under production load — CPU profiling, flame graph reading, ELU monitoring, and the full clinic.js diagnostic workflow for Node.js services under high-throughput stress.
Next: Module 12 — Production Observability, Performance Profiling & Flame Graphs →
In Domain-Driven Design (DDD), what is the difference between an Entity and a Value Object?
Which rule is fundamental to Clean Architecture?
What is the primary benefit of Command Query Responsibility Segregation (CQRS) in a state-heavy system?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
Sign in & RegisterDiscussion
0Join the discussion