The whole board
Sixteen rounds, and every box on the whiteboard arrived because something broke. This part walks the finished design end to end, traces one payment through every component that touches it, collects every tradeoff into one table, and then closes with the twenty questions most likely to be asked about it, answered.
The complete architecture, walked end to end
The eight layers, and the one thing each is responsible for
| Layer | Responsible for | Built in |
|---|---|---|
| Channels | Accepting instructions: app, POS, card network, partner API | Parts 8, 14 |
| Authorisation tier | Deciding yes or no inside a budget, with stand-in | Parts 8, 9 |
| Ledger | The invariant. Sole writer, sharded by account | Parts 1, 2, 3 |
| Event backbone | Telling everyone what happened, exactly once in effect | Part 4 |
| Product services | Product rules, expressed as balanced entries | Parts 5, 14 |
| Connectors | Money crossing our boundary, and the unknown state | Parts 6, 7 |
| Assurance | Proving the money is right, and catching adversaries | Parts 9, 10 |
| Data platform | Serving history, answering questions, keeping ten years | Parts 11, 15 |
A payment, traced through every component
One transfer: ₦50,000 from a Nigerian customer to an account at another bank, with a ₦10 fee. Here is everything it touches, with the part that introduced each step.
| t | What happens | Part |
|---|---|---|
0 ms | App sends POST /transfers with a client-generated idempotency key, over REST | 2, 14 |
3 | Gateway terminates TLS, authorises the customer, attaches a request id | 1 |
6 | Payments service resolves the beneficiary: NIBSS name enquiry, cached 60 s | 6 |
52 | Customer confirms the returned name. Last chance on a rail with no reversal | 6 |
55 | Velocity limits checked in Redis, atomically, in one Lua round trip | 5, 9 |
58 | Fraud score computed: rules, then the boosted model. Fails open | 9 |
70 | Sanctions screening. Fails closed, because it is statutory | 9 |
74 | Ledger shard resolved from hash(account_id) via the shard map | 3 |
76 | BEGIN. Journal row inserted, unique idempotency key | 1, 2 |
78 | Debit inserted with the balance check fused into the statement, floor from the overdraft facility | 1, 5, 14 |
79 | Credit to settle_suspense:nibss, so the money is in a named place | 6 |
80 | Fee entries: debit customer, credit fee_income:transfer:41, a sharded sub-account | 3, 5 |
81 | Invariant asserted in-transaction, per currency | 1, 7 |
82 | Outbox row written in the same transaction. No dual write | 4 |
84 | COMMIT. WAL fsync, synchronous replica acknowledges | 1 |
91 | 202 Accepted returned, with balance_effective: false stated honestly | 3 |
140 | Outbox relay publishes to Kafka, keyed by account id | 4 |
180 | Cache invalidated; the client's next read carries a watermark anyway | 3 |
210 | Notification consumer: dedup on event id, preferences, quiet hours, in-app written first | 12 |
400 | Dispatcher calls NIBSS, outside any transaction, with our journal id as the reference | 6 |
2.1 s | NIBSS returns accepted. State becomes awaiting_confirmation | 6 |
4 s | Bigtable updated, so the app's history screen shows it | 11 |
30 s | Continuous invariant check passes over this journal | 10 |
8 min | CDC lands the rows in bronze; silver conforms them | 11 |
2 h | NIBSS confirms settlement. Suspense cleared to the nostro account | 6 |
T+1 | Reconciled against the NIBSS settlement report, matched on our reference in pass 1 | 10 |
T+1 | Included in the close, trial balance signed off, figures frozen | 10 |
90 d | Partition ages to warm storage | 15 |
3 y | Archived to Parquet, manifest records the per-currency sums | 15 |
7 y | Retention expires. Still readable if a dispute requires it | 15 |
Every major tradeoff, in one table
| Decision | Chosen | Cost accepted | Would change if… |
|---|---|---|---|
| Balance representation | Derived from entries | O(n) reads, needing snapshots | Never. This is the foundation |
| Money type | Integer minor units | Exponent handling everywhere | Never |
| Ledger database | Postgres | Sharding needed for scale | DynamoDB at a DynamoDB-first shop, accepting a Streams-based invariant check |
| Consistency on the money path | Strong, CP | Availability during partitions | Never for posting. Authorisation got its own answer |
| Isolation level | READ COMMITTED plus conditional writes | Write skew needs explicit handling | SERIALIZABLE for transactions with multi-row invariants |
| Shard key | hash(account_id) | Cross-shard transfers need a saga | customer_id if intra-customer transfers dominated |
| Cross-shard atomicity | Saga with suspense | A brief non-atomic window | Co-locate related accounts if true atomicity were required |
| Event publishing | Transactional outbox | An extra write and a relay to run | CDC where the consumer wants table shape, as analytics does |
| Delivery semantics | At-least-once + idempotent handlers | Consumers must all deduplicate | Never. Exactly-once delivery does not exist |
| Fraud on the sync path | Fails open | Some fraud passes during an outage | Never. A fraud outage must not be a payments outage |
| Sanctions screening | Fails closed | Cross-border payments queue | Never. It is a statutory obligation |
| Card authorisation availability | Stand-in with caps | Bounded, quantified overdraw | Tighter caps if losses exceeded the model |
| Failover after a timeout | Forbidden for payments | Slower resolution of unknowns | Permitted for notifications, where a duplicate costs ₦4 |
| Analytics location | Fully isolated | Minutes of lag | Never. It is an availability decision |
| Region boundary | Legal, not technical | No cross-region failover | Never. It is not ours to change |
| Correction mechanism | New linked entries | Net effect is a recursive query | Never. Editability destroys the audit property |
What we deliberately did not build
Naming your own omissions before they are found is one of the strongest moves available, because it demonstrates that the scope was chosen rather than reached.
| Not built | Why it was excluded | What it would need |
|---|---|---|
| KYC and onboarding | A large domain of its own, mostly orthogonal to the ledger | Document capture, liveness, bureau checks, tiering |
| Authentication and sessions | Solved generically, with nothing bank-specific in the architecture | OAuth, device binding, step-up flows |
| Customer support tooling | Consumes the same events and data; adds no new structure | Case management, a read model, strict access control |
| The mobile app | A client of the APIs we designed | Offline handling, push registration, secure storage |
| Treasury and liquidity management | Genuinely important and largely a business function | Position limits, hedging execution, cash forecasting |
| Credit scoring models | A data science problem consuming our events | Bureau integration, feature engineering, model governance |
| Merchant acquiring | The other side of Part 8, and a full product line | Terminal estate, merchant onboarding, settlement |
| Multi-tenancy for BaaS | Would change isolation everywhere | Tenant in every key, per-tenant limits and reporting |
- Multi-tenancy. Retrofitting a tenant dimension into a sharded ledger is a migration of every row and every query. If BaaS were plausible, it belongs in round one.
- A different shard key. Changing from account to customer means rewriting the data. The logical-shard layer helps with rebalancing, not with re-keying.
- Making entries mutable. Impossible in practice, and the fact that it is impossible is the point.
- Changing the minor-unit convention. Every stored amount would need reinterpretation, and a partial migration would be silently wrong.
How this generalises beyond banking
The patterns here are not banking patterns. They are patterns for any system where a quantity must be conserved and the record must be trustworthy.
| Pattern | Also applies to |
|---|---|
| Double-entry with a conserved quantity | Inventory and warehouses, ad impressions and budgets, API credits, carbon accounting, in-game currencies |
| Append-only with derived state | Event sourcing generally, git, CRDTs, audit systems, anything needing time travel |
| Idempotency on the intent | Every API that mutates anything over an unreliable network |
| Transactional outbox | Any service that must update a database and tell somebody, which is most of them |
| Saga with a named intermediate state | Order fulfilment, seat reservation, multi-step provisioning, anything distributed and multi-step |
| Available versus actual | Inventory reservations, seat holds, rate-limit budgets, capacity planning |
| Split it, reassemble on read | Every hot key, hot partition, hot leader and hot lock you will ever meet |
| Reconcile against an external truth | Any integration where both sides keep records, which is every integration |
| Correctness as a monitored signal | Any system that can be fast, available and wrong at the same time |
the four constraints that produced all of it:
1. a quantity must be conserved
2. networks are unreliable and duplicates happen
3. multiple parties keep their own records
4. somebody has to prove it was right, later
wherever all four hold, this architecture is the answer,
whatever the quantity happens to be.Defending it: the twenty hardest questions
The questions most likely to follow this design, with the answer in the form it should be given: direct, then the reason, then the cost.
in_transit:{shard}, a real account with a real balance. It is never nowhere. Anything lingering there past a threshold is an alert, and that same account is a reconciliation surface in round ten.