The shape of the system
The six contexts
The dependency direction is the thing to hold onto: everything is an interface onto the ledger. Transfers, fees, FX, reversals, reconciliation and reporting are all ways of asking the ledger a question or telling it something happened. That is why it was built first, and why it is the page to read if you only read one.
The boundary rule
A context may import from
This is what makes “modular monolith” a checked claim rather than a label. Two independent mechanisms enforce it, and both are needed:
packages/* and from its own directory. It may never import another
context’s code. A cross-context import fails the build.1
dependency-cruiser catches relative imports
../../ledger/src/posting is caught immediately. This is the obvious case and the one people expect.2
ESLint no-restricted-imports catches package-name imports
@arc/ledger is not caught by dependency-cruiser, because it resolves through node_modules to a dist path that does not exist until after a build, so the rule silently skips it.That gap was found by deliberately probing both mechanisms rather than by reading the configuration. It is a scenario in itself, and it is the reason both layers exist.How contexts actually talk
Two mechanisms, chosen by whether the caller can wait.Events, for anything asynchronousProduct publishes
virtual_account.issued. The ledger subscribes and provisions the matching liability account. Neither imports the other; the only shared thing is the schema in @arc/contracts.This is the default and covers most inter-context communication.Ports, for anything synchronousMovement needs the ledger now: a reservation must be accepted or rejected before the saga proceeds, so an event round-trip will not do.Movement therefore defines
LedgerPort and CompliancePort as interfaces it owns, and the other contexts implement them. The dependency is inverted; movement never imports the ledger.src, which is how the boundary stays clean while the call stays synchronous.
The event bus and the outbox
Events are staged in a transactional outbox: written to a database table in the same transaction as the state change that produced them, then dispatched separately.Delivery is at-least-once; processing is effectively-once. A handler that already succeeded is
never re-run on retry, and a poison event is parked for review rather than dropped or left
blocking the queue.
The alternative, publish after commit, has a window in which the state change lands and the event does not, which produces exactly the silent divergence that is impossible to debug six weeks later. The outbox trades that for duplicate delivery, which is a problem you can solve with idempotency. Contexts and events covers the mechanism.
A transfer, end to end
Every step has a compensating action, and this is enforced rather than intended: the chaos suite fails each of the five saga steps in turn and asserts the ledger is balanced in every currency, the sender’s balance is exactly what it was, and every intermediate account is back to zero.The stack, and why
Where to go next
Why money is never a float
The decision everything else rests on, and the lint rules that enforce it.
The ledger
Chart of accounts, the balance rule, and a worked EUR→KES transfer entry by entry.
The settlement saga
Five steps, five compensations, and why compensating in the wrong order produces a balanced
ledger that lies.
What the tests prove
Property-based, mutation-checked, chaos-injected. The claims on this site have receipts.