
Core banking architecture: event sourcing and CQRS
A core banking architecture built on event sourcing and CQRS records every money movement as an immutable event, then derives balances, statements and reports by folding those events into read models. The write path validates a command against an aggregate and appends events. The read path serves projections built from those events. Nothing ever overwrites a balance.
For the part of a bank that touches money, that is the right default. The hard question is not whether to use event sourcing. It is how many bounded contexts you carve the bank into, because that is the decision you keep paying for: at every schema migration, every audit, every time a new engineer asks where a balance actually comes from.
There is now a concrete reference to argue with. The FinAegis core banking prototype, pushed again in the last week, models a bank as 61 domain modules on top of event sourcing and CQRS. Alongside it, Midaz makes almost the opposite bet with three components. Reading both against each other is the fastest way to work out where your own boundary belongs.
How event sourcing and CQRS work in a ledger
Event sourcing, in Martin Fowler's framing, captures all changes to application state as a sequence of events, so that "application state is purely derivable from the event log". CQRS is the companion move: separate the model you write through from the models you read from. They are independent patterns, but in a ledger they arrive together, because an append-only log is miserable to query directly.
Concretely, a transfer looks like this.
One transfer through an event-sourced core
Step 1 of 5Command
TransferMoney arrives with an idempotency key, a source account, a destination account, an amount and a currency.
The payoff is that a balance stops being a number someone typed over and becomes a claim you can prove. Ask "why is this account at 4,182.50?" and the answer is a list of facts that sum to it. That property is what makes double-entry ledger invariants enforceable rather than aspirational: if debits and credits are events, the sum-to-zero rule is checkable over the whole history, not just the last write.
The problem it solves is the mutable balance
The alternative most products start with is one statement:
UPDATE accounts SET balance = balance - 500 WHERE id = 42;
That line is fast, obvious, and destroys evidence. After it runs you know the balance changed and nothing about why. Six months later a customer disputes a fee, a regulator asks for the state of an account on a specific date, or you find a 12 cent drift between your ledger and your PSP, and there is no record to reason from. You are reduced to inferring history from application logs, which is exactly the position that makes payment reconciliation turn into a permanent tax on the team.
Event sourcing removes that class of problem outright: temporal queries, full audit trails and corrective replay all fall out of the log. That is a genuine, durable win, and it is why finance keeps reaching for the pattern even though the learning curve is real.
Two reference designs that disagree
Here is where the two open-source references part ways.
| Core shape | 61 domain modules in one Laravel app | 3 components (ledger, tracer, infra) | One PostgreSQL schema, logic close to the data |
| Source of truth | Per-domain event streams | Balance and operation rows | Accounting tables |
| Event sourcing | Yes, via Spatie Event Sourcing | No, immutable hash-chained audit trail instead | No |
| CQRS | Yes, projectors to read models | Yes, hexagonal command and query split | No |
| Concurrency control | Append to the aggregate stream | Optimistic locking on a balance version | Database transactions |
| Stack | Laravel 12, PHP 8.4 | Go, PostgreSQL and MongoDB | Perl 5.38, PostgreSQL 14 |
| Commits | 2,254 | 7,904 | 19,438, lineage from 1999 |
| Stated status | Demonstration platform | Source-available product | Production accounting and ERP |
Midaz is the interesting counterweight because it is not event-sourced at all, and says so in its code. Its ledger persists balances as rows and guards them with a version column, updating only where the stored version is older than the incoming one. Money history lives in operation rows and a hash-chained audit trail rather than in a replayable stream. Its hierarchy (organizations, ledgers, assets, portfolios, segments, accounts, transactions, balances) is eight concepts, not sixty-one, and it deliberately pushes rails such as PIX, cards and wires outside the core.
LedgerSMB is the third data point and the least fashionable: a double-entry accounting system whose lineage runs back to 1999, still on Perl and PostgreSQL, with no event sourcing anywhere. It is worth including precisely because it has outlived most of the architectures that would have called it naive.
What a domain count actually tells you
FinAegis's headline number is 61 DDD domains as self-contained, individually installable modules. Counting the repository tree confirms it: exactly 61 directories under app/Domain, holding 2,367 files between them. So the claim is real, not marketing. The question is what those 61 boundaries buy.
Two things stand out once you count files rather than folders.
Files per domain folder in the FinAegis prototype (61 total)
First, the distribution is extremely lopsided. Twelve of the 61 domains hold half the code. The tail is folders, not contexts: Activity and Referral have 3 files each, Contact has 4, Auth has 6. A bounded context with three files in it is a namespace someone was optimistic about.
Second, and more telling, the Ledger domain has 18 files. In a core banking platform, the ledger is smaller than Mobile (44), smaller than Privacy (49), and less than a tenth the size of AgentProtocol (208). The domain count tells you nothing about where the money model lives, and in this case the money model is not where the weight is.
Then there is the question of what counts as a domain at all. Fowler's bounded context is a boundary drawn where the language changes: a place where "meter" or "account" means something different to a different group of people. Measured against that, roughly twenty of the 61 are not bounded contexts in any useful sense. SMS and Mobile are delivery channels. Webhook is a transport. ISO8583 and ISO20022 are message formats, which is to say adapters. Auth, Security, Privacy, Monitoring and Performance are cross-cutting concerns that cannot be bounded because they apply everywhere. Shared is, by name, the opposite of a bounded context. VisaCli is a command line tool.
Naming is not modelling. Putting a concern in its own folder does not give it a ubiquitous language, an aggregate, or an invariant it owns. It gives it an import path. If you cannot say in one sentence which rule about money a context protects, it is not a context yet.
None of this makes the prototype bad. It explicitly calls itself a demonstration platform and says a security audit and compliance review are required before production use, which is a more honest posture than most repos in this space manage. But if you are using it as an argument for modelling your own bank as dozens of contexts, the file counts are the part to read.
The bill, and when it arrives
Event sourcing's costs are real and they are deferred, which is the worst combination for a startup making architecture decisions at month three.
Event schemas are forever. Once an event is in the store, every future version of your code must be able to read it. Adding a field is not a migration, it is an upcasting function you now maintain indefinitely. This is the highest-cost change in the pattern, so decide your versioning approach before the first event lands, not after ten million have.
Projection rebuilds get slow, then scary. Rebuilding a read model from scratch is inevitable (you will fix a projector bug, or add a report that needs history). At low volume it takes seconds. At production volume it takes hours, during which that read model is stale or offline. Design the rebuild path, and a way to run a new projection alongside the old one, from day one.
Replay and the outside world do not mix. Fowler flags this as the tricky part, and he is right: replaying events must not re-send yesterday's payouts, emails or webhook calls. Every gateway to an external system needs an explicit replay mode. Get this wrong once and you have paid a vendor twice.
Reads are eventually consistent, and users notice. The transfer succeeded but the balance still reads old, because the projector has not caught up. In a payments UI that reads as a bug even when the ledger is perfectly correct. You either read the aggregate for the paths that must be immediate, or you design the interface to show pending state honestly.
Deletion is genuinely hard. An append-only log and a right-to-erasure request are in direct tension. Crypto-shredding (encrypt personal data per subject, then destroy the key) is the usual answer, and it needs to be in the design before a regulator asks.
One thing event sourcing does make easier: because commands carry keys and the aggregate can see its own history, deduplication has somewhere natural to live. That still does not replace proper idempotency keys on payment APIs at the edge, but it does mean the two layers reinforce each other instead of both guessing.
Where to draw the boundary
The pattern that holds up: one bounded context owns the money invariant, and everything else is an adapter or a genuinely separate service with its own store.
Practically, apply three tests before promoting anything to a context of its own. Can you state, in one sentence, the invariant it protects? Does it own data that no other context may write? Would a single team be able to own it end to end? A context that fails all three is a package, and calling it a domain just adds a hop between you and the code.
For most fintech products this collapses to a small core: accounts and balances, postings, plus one aggregate per thing that genuinely has its own lifecycle and rules (a loan, a card, a settlement batch). Compliance, treasury and reporting are usually consumers of the ledger's event stream rather than peers of it. Rails are adapters. Notifications are subscribers. That shape is close to what Midaz ships, and it is also what tends to survive contact with the second and third payment provider you integrate.
When to skip it
Event sourcing is the wrong call more often than its fans admit. Skip it when the domain is CRUD with an audit column bolted on, when nobody will ever ask what state something was in last March, or when your team does not yet have the operational maturity to run projections, replays and two consistency models at once. Skip it for the parts of the product that are not money: user profiles, content, settings. Mixing a mutable model for those with an event-sourced core is not inconsistency, it is choosing the cost where it pays.
And take LedgerSMB seriously as evidence. Twenty-seven years of double-entry accounting, no event streams, still running businesses. Correct money handling comes from taking invariants seriously, not from the persistence pattern. Event sourcing makes the right invariants cheaper to prove. It does not supply them.
If you are picking this architecture now and want a second opinion on where the boundary should sit before the first million events land, that is a conversation worth having early: our fintech engineering team does exactly this kind of review, and you can get in touch with the specifics of your ledger.
New posts, in your inbox
Get an email when we publish a new deep-dive. No spam, unsubscribe anytime.