ddd-bounded-contexts
DDD: Bounded Contexts, Aggregates & Database-per-Service
The problem: where do you cut?
The hardest microservices question isn't technical — it's where the boundaries go. Cut wrong and you get a distributed monolith: services that must deploy together, share tables, and call each other synchronously in chains. You pay the operational tax of distribution and get none of the autonomy. DDD's strategic patterns are the established answer to "where to cut".
Bounded contexts in this project
A bounded context is a boundary inside which a model and its language are consistent. Ours:
| Accounts context | Transfers context | |
|---|---|---|
| Owns | balances, the ledger, "can this account pay?" | the transfer process, "where is this transfer in its lifecycle?" |
| Language | account, balance, debit, credit, ledger entry | transfer, saga state, compensation |
| Service | account-service (.NET + PostgreSQL) | transfer-service (NestJS + MongoDB) |
| Knows about the other | nothing but its events | nothing but its events |
The same word means different things across the line — a "transfer" in the Accounts context is just a transferId tag on ledger entries; in the Transfers context it's a rich state machine. That asymmetry is correct: forcing one shared model (the classic "enterprise canonical model" mistake) couples everyone to everyone.
Context mapping: the two contexts integrate via published language — the event contracts in contracts/events/. Neither imports the other's types; each maps the envelope into its own model at the boundary (Contracts/ in .NET, interfaces in account-events.handler.ts). That mapping layer is a lightweight anti-corruption layer: when the other side changes, the blast radius is one file.
Aggregates: the consistency boundary
An aggregate is the largest cluster of objects you mutate in ONE transaction, guarded by invariants, behind one root.
Account (account-service/src/AccountService/Domain/Account.cs) is the textbook case:
- Invariant: balance never goes negative — enforced in
Debit(), the only door. No service method can bypass it because the setter is private. - Everything that must be true immediately lives inside (balance + the ledger entry for each change, written in one transaction).
- Everything that can be true eventually lives outside (the destination account's credit — a separate aggregate, reached via the saga, not via a 2-aggregate transaction).
- Concurrent mutation is resolved by optimistic concurrency (the
xminrow-version): two simultaneous debits → one wins, one retries against fresh state. The invariant holds under race.
Rule of thumb that interviews love: one transaction = one aggregate. The moment you need two, you need a saga (saga-choreography.md).
Database-per-service — stated explicitly
Each service owns its schema and nobody else touches it. PostgreSQL fits the Accounts context (relational integrity, row versioning, real transactions for money); MongoDB fits Transfers (document = saga instance, flexible history array). Polyglot persistence is a consequence of the boundary, not the goal.
What this costs (be honest about it):
- No joins across contexts — "show transfers with account names" needs API composition or a read model (Phase 2 CQRS).
- No cross-context transactions — hence the saga and everything in this repo.
- Eventual consistency is visible — and must be a product decision, not a surprise.
Anti-patterns this design dodges (name them in interviews)
- Shared database: both services on one DB = schema coupling, lock contention, deploy coupling. The DB becomes the API, and DBs make terrible APIs.
- Distributed monolith: if account-service and transfer-service had to deploy in lockstep, the events versioning discipline (event-contracts-versioning.md) is what prevents it.
- Chatty synchronous chains: transfer-service never calls account-service's REST API mid-saga. A→B→C HTTP chains multiply latency and compound availability (99.9%³ ≈ 99.7%) — async events decouple availability.
- Entity service ("CRUD service per table"): our services own behavior (a process, a ledger), not table rows.
Interview Q&A
Q: How did you decide these are two services and not one? Different invariants (immediate balance consistency vs eventual process consistency), different change cadence, different data shapes — and the language splits naturally. If I couldn't articulate that, one service would be the right answer: microservices are a cost you pay for boundaries you can defend.
Q: Why is the transfer's amount duplicated in both contexts? Each context stores what it needs to do its job: Transfers stores the requested amount (its process data); Accounts records the movement in ledger entries (its facts). Duplication across contexts is normal — shared mutable state is the thing to avoid. They reconcile through events, not joins.
Q: What's the difference between a bounded context and a microservice? Bounded context is a model boundary; a service is a deployment boundary. Ideal: 1 context = 1 service (ours). Acceptable: several contexts in one deployable (modular monolith). Disaster: one context smeared across several services — they can never deploy independently.
Q: When would you merge these services back together? If the team is one person and the operational overhead outweighs autonomy — a modular monolith with the SAME internal boundaries (separate modules, separate schemas, events between them) preserves the design while collapsing the ops cost. Boundaries are the asset; the network hop is negotiable.
Try to break it
- Try to "just query" an account's name from transfer-service: there is no connection string to PostgreSQL anywhere in the Node code. The absence of access is the architecture.
- Write the SQL you'd need for "balance went negative" with the aggregate bypassed:
UPDATE accounts SET "BalanceMinor" = -1 ...works in psql — and now find any path in the C# code that could produce it. There isn't one; that's the aggregate doing its job. - Sketch where a third context (e.g. Fraud) would attach: which events would it consume? Would anyone need to change? (No — that's the payoff.)