This is a walkthrough of the Virtual Bank System, a small bank built as event-driven microservices in Spring Boot. The code is small and meant to be read: the goal here is to explain how the parts fit and why the money path is correct. A transfer cannot double-spend, cannot drive a balance negative, is idempotent under retries, and survives a service or broker restart without losing or inventing money. The code is on GitHub and the whole thing comes up with one command. I have organized this around the concepts a learner can carry out of it, each tied to the running system.
What the app does
Before the internals, here is the system from the outside. A React single-page app talks to one gateway. You sign in, see your accounts and balances, open accounts, deposit, and transfer money between accounts. Every transfer keeps a history you can read back.

Opening an account is a short dialog that takes a type and a currency, and the new card starts at a zero balance. Depositing is a similar dialog scoped to one account. A deposit is a synchronous credit handled inside account-service, so the new balance is visible the moment the call returns.


The transfer screen is where the event path becomes visible. You pick a source account and a destination, enter an amount, and send. The app follows the transfer to its outcome and shows the audit history that audit-service built by consuming the event streams. Here a transfer of $750.00 has settled to COMPLETED, with its history listing REQUESTED and then COMPLETED, each with a timestamp. Those two entries come from two different events on the event path, recorded independently. That ordered, two-step history is what the rest of this post explains.


The system at a glance
Five Spring Boot services sit behind a Spring Cloud Gateway. The gateway is the only thing reachable from outside: it validates the request's token, routes /api/** to a service, and aggregates the dashboard. Behind it, four services each own a single responsibility, backed by one PostgreSQL server (a database per service) and one KRaft Kafka broker. An optional Spring AI assistant over OpenRouter can be turned on (it uses a local embedding model for retrieval and degrades gracefully without a key), and so can a Tempo, Prometheus, and Grafana stack for observability.
The split between the two arrow styles is the first concept to hold onto. Everything a client does is a synchronous request over HTTP: register, log in, open an account, deposit, read balances, start a transfer, read a transfer's status. Exactly one thing is asynchronous over Kafka: the transfer itself. Starting a transfer returns 202 Accepted immediately, and the actual money movement happens on the event path while the client polls for the result. So the system is synchronous at the edge and event-driven in its core, where the consistency requirements concentrate.
- gateway (Spring Cloud Gateway): the single entry point. It validates the JWT, routes
/api/**, and aggregates the dashboard. - user-service: identity. Registration, login, RS256 JWT issuance, and a JWKS endpoint that publishes its public keys.
- account-service: accounts and balances. It applies a transfer atomically; both the debit and the credit live here.
- transaction-service: the transfer ledger. It records a transfer, orchestrates it on the event path, and marks its outcome.
- audit-service: an event-sourced, queryable history. It is a separate consumer of the transfer streams and records one immutable entry per event.
The stack is Java 21 with virtual threads, Spring Boot 3.5, Spring Cloud Gateway, Spring Kafka on a single KRaft broker, Spring Security as an OAuth2 resource server (RS256 JWT plus JWKS), Spring Data JPA with Flyway, PostgreSQL, Micrometer Tracing with OpenTelemetry, and Testcontainers. Shared events, the outbox, and the security code live in a vbank-common Spring Boot starter so the services cannot drift apart on them.
Database per service
Each service owns its own schema in PostgreSQL, and no service reads another service's tables. user-service holds users, account-service holds accounts and balances, transaction-service holds the transfer ledger, and audit-service holds the immutable audit entries. Two extra tables appear only in the services that publish or consume events: an OUTBOX table and a PROCESSED_EVENTS table, both explained in the sections below. Keeping the data private to each service is what lets them deploy and evolve independently, and it is why they communicate only through the gateway's API or through Kafka, never by sharing a database.
The transfer saga, step by step
A transfer is coordinated by transaction-service and executed by account-service. The two services never call each other directly; they exchange a request and a result over Kafka, and each writes those messages through a transactional outbox. Both accounts live in account-service's database, so the debit and the credit are a single local ACID transaction. The transfer still routes through Kafka rather than a direct call, because that gives a durable, replayable, audited record, and it leaves room for the destination to move to another service or bank later.
Reading the flow in order:
- The client posts the transfer with an
Idempotency-Keyheader. The gateway validates the JWT and forwards it to transaction-service. - transaction-service writes a
PENDINGtransfer and aTransferRequestedevent into its outbox in one database transaction, then returns202 Acceptedwith the transfer id and a status URL. - A relay reads unsent outbox rows and publishes the
TransferRequestedevent to thetransfer.commandstopic. - account-service consumes the event, takes a pessimistic write lock on both accounts, validates, and applies the debit and credit in one atomic, idempotent local transaction, then emits
TransferCompletedorTransferFailedon thetransfer.eventstopic through its own outbox. - transaction-service consumes the result and marks the transfer
COMPLETED, orFAILEDon insufficient funds. - audit-service is a separate consumer reading both
transfer.commandsandtransfer.events, so it records the REQUESTED entry and the outcome entry independently. - The client polls the status URL and reads
COMPLETEDorFAILED.
A poison message, anything that keeps failing, is routed to a per-topic dead-letter topic so one bad record does not block the partition or get silently dropped. Three properties make this correct: the transactional outbox, idempotent consumers, and pessimistic locking. Each maps to a label on the saga diagram above, and the next three sections take them one at a time.
The transactional outbox: events are never lost
The naive way to publish an event is to save your row, then send to Kafka. If the process dies between those two steps, the state changed but nobody heard about it, or the reverse. The outbox pattern removes that gap. A service writes the business row and a row describing the event into the same database, in the same transaction. A small relay later reads unsent outbox rows and publishes them to Kafka. The event cannot exist without its state change committing, and once written it cannot be dropped. In this system the OUTBOX table lives in both account-service and transaction-service, since both publish events. This is why a crash mid-transfer is safe: when the service comes back, the relay picks up where it left off.
Idempotent consumers: money never moves twice
Kafka delivers at least once, so account-service assumes it will see the same transfer request more than once. Before it touches a balance, it records the transfer id in its PROCESSED_EVENTS table, in the same transaction as the money movement. A redelivery finds the id already there and does nothing. The transfer id is the idempotency key all the way through, including the client's Idempotency-Key header, so re-posting the same transfer returns the same one instead of creating a second. The same guard protects transaction-service when it consumes results, and AUDIT_ENTRIES is unique on (transfer_id, event_type) so a redelivered event is recorded once.
Pessimistic locking: no double-spend
When account-service applies a transfer it takes a pessimistic write lock on both accounts, in a fixed id order so two opposite-direction transfers cannot deadlock, then moves the money. Concurrent transfers on the same account serialize instead of racing. A CHECK (balance >= 0) constraint is the backstop that makes a negative balance impossible even if the logic were wrong. Because both accounts share account-service's database, the debit and the credit are a single ACID transaction that either fully happens or does not.
A concurrency test verifies this. It seeds an account with 100.00, then fires twenty transfers of 10.00 at it from twenty threads released at the same instant by a start latch, and waits on a done latch. The source can fund exactly ten of the twenty, so the lock must serialize them: the balance lands on exactly 0.00, the destination on 100.00, and the outbox holds ten TransferCompleted results and ten INSUFFICIENT_FUNDS failures. The assertions read the outbox directly rather than a broker, so the test needs no Kafka:
@Test
void concurrentTransfersNeverDoubleSpend() throws InterruptedException {
Account source = seedAccount(OWNER, new BigDecimal("100.00"));
Account destination = seedAccount(OWNER, new BigDecimal("0.00"));
int transfers = 20;
BigDecimal amount = new BigDecimal("10.00");
ExecutorService pool = Executors.newFixedThreadPool(transfers);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(transfers);
for (int i = 0; i < transfers; i++) {
pool.submit(() -> {
try {
start.await(); // release all at once
transferService.apply(command(source, destination, amount));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
start.countDown();
assertThat(done.await(60, TimeUnit.SECONDS)).isTrue();
// the source funds exactly 10 of 20; it lands on 0 and never goes negative
assertThat(balanceOf(source)).isEqualByComparingTo("0.00");
assertThat(balanceOf(destination)).isEqualByComparingTo("100.00");
// 10 TransferCompleted and 10 INSUFFICIENT_FUNDS in the outbox
}Security: every service validates the token
user-service issues an RS256 JWT on login and publishes its public keys at /.well-known/jwks.json. The gateway validates that token, and so does every service behind it, against the same JWKS. This is defense in depth: a request that somehow reaches a service without passing through the gateway still cannot read or move another user's money.
- The user id always comes from the token's
subclaim, never from a request parameter or body. - Ownership is checked on every account and transfer resource, so one user cannot read or move another user's money.
- Passwords are hashed with BCrypt.
- Secrets come from the environment, never the source tree.
Observability: a trace that crosses Kafka
Each service bridges Micrometer observations to OpenTelemetry and exports spans over OTLP. Kafka producer and consumer observation is turned on, so the W3C traceparent header rides along on every record and the trace continues on the far side of the broker: a single trace shows transaction-service publishing a command and account-service plus audit-service consuming it. An opt-in compose override adds Tempo for traces, Prometheus for metrics, and Grafana to view both. Tracing is off in the lean default run and on under the override.
Running it
The only requirement is Docker (or podman) and Docker Compose; the services build from source inside the images. One command brings up PostgreSQL, a single KRaft Kafka broker, and the five services, with the gateway on http://localhost:8080:
git clone https://github.com/Ab-Romia/Virtual-Bank-System.git
cd Virtual-Bank-System
cp .env.example .env # adjust the Postgres password for anything real
docker compose up --build # gateway on http://localhost:8080From there you can walk a transfer end to end with curl: register and log in to get a token, open two accounts and fund one, post a transfer with an Idempotency-Key, then read the result and the audit trail.
# register and log in
curl -s -XPOST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
-d '{"username":"alice","email":"a@example.com","password":"pw123456","fullName":"Alice"}'
TOKEN=$(curl -s -XPOST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"pw123456"}' | sed 's/.*"accessToken":"\([^"]*\)".*/\1/')
AUTH="Authorization: Bearer $TOKEN"
# open two accounts, fund one
A=$(curl -s -XPOST localhost:8080/api/accounts -H "$AUTH" -H 'Content-Type: application/json' -d '{"type":"CHECKING","currency":"USD"}' | sed 's/.*"id":"\([^"]*\)".*/\1/')
B=$(curl -s -XPOST localhost:8080/api/accounts -H "$AUTH" -H 'Content-Type: application/json' -d '{"type":"SAVINGS","currency":"USD"}' | sed 's/.*"id":"\([^"]*\)".*/\1/')
curl -s -XPOST localhost:8080/api/accounts/$A/deposit -H "$AUTH" -H 'Content-Type: application/json' -d '{"amount":100}' >/dev/null
# transfer 30 (returns 202 with a transfer id), then read the result and the audit trail
TX=$(curl -s -XPOST localhost:8080/api/transfers -H "$AUTH" -H 'Content-Type: application/json' -H 'Idempotency-Key: demo-1' \
-d "{\"fromAccountId\":\"$A\",\"toAccountId\":\"$B\",\"amount\":30,\"currency\":\"USD\"}" | sed 's/.*"transferId":"\([^"]*\)".*/\1/')
curl -s localhost:8080/api/transfers/$TX -H "$AUTH" # COMPLETED or FAILED
curl -s localhost:8080/api/audit/transfers/$TX -H "$AUTH" # the REQUESTED + COMPLETED trailTwo optional profiles add to the lean run when you want them: an observability override that brings up Tempo, Prometheus, and Grafana on http://localhost:3000, and an AI profile that starts the Spring AI assistant when you supply a free OpenRouter key. Running ./mvnw verify exercises the unit and Testcontainers integration tests, including the concurrency test above and the saga, idempotency, and audit tests.
What to take away
The core of this project is the three mechanisms that make the events trustworthy: the transactional outbox so events are never lost, idempotent consumers keyed by the transfer id so at-least-once delivery never moves money twice, and pessimistic locking plus a CHECK (balance >= 0) constraint so concurrent transfers cannot double-spend. The gateway, the per-service databases, the audit log, and the tracing all exist to make those mechanisms easy to inspect. The full diagrams and reasoning, including where the trace stops, are in the architecture notes.