DigiWallet: Building an Event-Driven Wallet with Spring Boot, RabbitMQ, and the Saga Pattern

A walk-through of DigiWallet — a four-service Spring Boot system that uses event sourcing, CQRS, and sagas to move money safely across independent microservices.
When I set out to build DigiWallet, my goal wasn't to ship yet another CRUD app. I wanted to feel — really feel — what event-driven microservices are like when money is on the line: what happens when a service goes down mid-transfer, how you keep balances consistent without distributed transactions, and why "just call the other service" is almost always the wrong answer.
This post walks through the architecture and, along the way, unpacks the vocabulary — event-driven, event sourcing, CQRS, Saga, aggregate, idempotency — the same way I had to learn it: one problem at a time.
Why not just call the other service?
The natural first instinct is to have the transfer service call the project service over HTTP: "please debit account A, then please credit account B." It works on a whiteboard. In production it doesn't, because the moment you introduce a network you inherit its failure modes: timeouts, partial writes, the callee crashing after committing but before responding. Now the caller doesn't know if the debit happened. Retrying is dangerous (double-debit). Not retrying is dangerous (money lost). This is the two-generals problem in a hoodie.
Event-driven architecture flips the interaction around: services don't call each other, they announce facts. project doesn't ask wallet for permission — it publishes "an account was debited" to a message broker, and any interested service reacts. The broker (RabbitMQ, in DigiWallet's case) keeps the message durable until every subscriber has processed it. If a consumer is down, the message waits. If it crashes mid-handling, the broker redelivers. Coupling drops from "I know who calls me" to "I emit facts; whoever cares can listen."
Event Sourcing: storing the cause, not just the state
Most CRUD apps store the current state: balance = 500. When someone asks "why is it 500?", all you have is the number. In event sourcing, you store the sequence of things that happened — AccountCreated, Deposited 300, Deposited 200 — and derive the balance by replaying them. The events are the source of truth; the balance is a computed view.
Why bother? Three reasons that showed up almost immediately in DigiWallet:
1. Perfect audit trail. Every state change is an immutable, timestamped, signed row in an append-only log. Regulators love this. So do you, at 3 a.m., trying to figure out why an account is off by 12 lira. 2. Time travel. You can rebuild the state as of any moment by replaying events up to that timestamp. Bug in a projection? Rebuild it. New read model? Replay history into it. 3. Natural fit for publish/subscribe. The same events you persist are the events you emit to other services. No separate "notification" layer.
The project service in DigiWallet holds an aggregate — a domain object (an Account) that owns a consistent slice of state and is the only thing allowed to mutate it. Commands (Deposit, Withdraw) hit the aggregate, it validates the invariant ("balance must not go negative"), and if the command is legal it emits an event. The event is appended to the log and published. The aggregate never persists its current balance — it rehydrates it by replaying its own events on the next command.
Saga: distributed transactions without distributed transactions
A transfer touches two aggregates: debit source, credit destination. In a monolith this is one database transaction and you sleep well. Across services with separate databases, that transaction doesn't exist. You could reach for a two-phase commit protocol, but 2PC blocks under partition and doesn't scale — which is exactly why nobody uses it in practice.
The Saga pattern solves this differently: instead of one atomic transaction, you run a sequence of local transactions, each one publishing an event that triggers the next. If any step fails, you emit a compensating event that semantically reverses the earlier steps. There is no rollback — there is a new, forward-moving action that says "undo the debit."
In DigiWallet, a transfer looks like this:
1. transfer receives a request and writes TransferInitiated to its own store. 2. project handles the debit locally, emits AmountDebited, or emits DebitRejected if the balance is insufficient. 3. On AmountDebited, project performs the credit on the destination account and emits AmountCredited. 4. transfer sees AmountCredited and marks the saga complete. 5. If step 3 fails, project emits a compensating AmountRefunded on the source, and transfer marks the saga as failed.
At no point does any service block waiting for another. At no point is there a distributed lock. Consistency is eventual — for a few hundred milliseconds the source account reflects the debit but the destination doesn't yet reflect the credit — and that's an explicit, acceptable tradeoff.
RabbitMQ fanout: one publisher, many listeners
The events flow through a fanout exchange in RabbitMQ. A fanout doesn't route by key — it copies every published message to every bound queue. project publishes AmountDebited once; the transfer service's queue gets it (to advance the saga), the statement service's queue gets it (to update read models), the notification service's queue gets it (to email the user). None of them know about each other. Adding a new consumer tomorrow — say, a fraud-detection service — is a matter of binding a new queue to the exchange. Zero changes to the publisher.
Each consumer processes messages independently. If statement is slow, its queue backs up but nobody else notices. If notification crashes, its unacknowledged messages are redelivered when it recovers.
Idempotency: the price of at-least-once delivery
Message brokers are honest about the tradeoff: they guarantee at-least-once delivery, not exactly-once. If a consumer processes a message and crashes before acknowledging it, the broker redelivers. Your handler will see the same event twice.
For a "send email" handler this is a mild annoyance. For "credit 500 lira to account B" it is a disaster. The fix is idempotency: designing handlers so processing the same event twice has the same effect as processing it once. In DigiWallet every event carries a unique id; before applying it, the aggregate checks whether that id has already been recorded, and skips the mutation if so. Cheap on the write path, life-saving in production.
CQRS: separate the read model from the write model
The project service is beautiful for writes — aggregates, invariants, event logs — but painful for reads. "Show me the last 20 transactions on account A, paginated, filtered by date range, sorted by amount" is a query the write model was never designed to answer, and forcing it to would leak query concerns into the domain logic.
CQRS (Command Query Responsibility Segregation) is the deliberate split: commands go to the write model (the aggregates), queries go to a separate read model shaped exactly for the queries the UI needs. The statement service in DigiWallet is a read model. It listens to the same events flowing through RabbitMQ, projects them into denormalized tables (transactions_by_account, monthly_totals), and serves them behind fast, single-purpose endpoints. If the read model is corrupted or a new one is needed, you replay the event log to rebuild it — no schema migration, no data backfill.
The read side is eventually consistent with the write side by a few milliseconds. For a statement view, that is fine. For "can this account afford the withdrawal I'm about to make?", you never ask the read model — you ask the aggregate, because only it owns the invariant.
What's missing (and why I know it)
Production event-driven systems have three more pieces I deliberately left out of the first iteration, because I wanted to feel their absence:
- Transactional Outbox. Right now the aggregate writes an event to its store and then publishes it to RabbitMQ. If the process dies between those two steps, the event is persisted but never published — the saga stalls forever. The fix is to write the event to an "outbox" table in the same database transaction as the state change, and have a separate publisher poll that table and forward to the broker. Now the write and the "intent to publish" are atomic.
- Snapshots. Rehydrating an aggregate by replaying ten thousand events on every command works until it doesn't. Snapshots let you persist the aggregate's state every N events so rehydration only replays the tail.
- Dead-Letter Queue. A message that keeps failing after retries needs to go somewhere quiet where a human can look at it, instead of blocking the queue behind it. That "somewhere" is the DLQ, and every production consumer should have one.
Each of these is a post of its own.
Takeaways
Event-driven architecture is not "microservices with a message queue." It is a discipline: you stop asking services to do things for each other and start letting them react to facts. In exchange for accepting eventual consistency and doing the hard work of idempotency, you get systems that keep going when parts fail, that can be extended without changes to existing services, and that produce their own audit trail as a byproduct of how they work.
If you want to poke at the code, it's on GitHub: eneeesyk/digiwallet.