Skip to content
Back to Blog
12 min read

The Virtual Bank System: event-driven microservices and a correct transfer saga

A study guide to the Virtual Bank System: five Spring Boot services behind a gateway, synchronous REST at the edge and an event-driven transfer saga over Kafka. It explains the architecture, the transfer step by step, and the three mechanisms that keep the money path correct: the transactional outbox, idempotent consumers, and pessimistic locking.

MicroservicesEvent-DrivenSpring BootKafkaDistributed SystemsBackend

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.

The Virtual Bank dashboard. Two account cards: a CHECKING account ending 4917 with a $3,200.00 balance marked ACTIVE, and a SAVINGS account ending 7908 with a $0.00 balance marked ACTIVE. An Open account button sits to the right and a Recent transfers panel below reads No transfers yet.
The dashboard after sign-in: account cards with balances and status, aggregated by the gateway into one response.

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.

An Open account dialog over the dashboard, with an Account type field set to Checking and a Currency field set to USD, and Cancel and Open account buttons.
The open-account dialog: pick a type and a currency. This is a synchronous request to account-service.
A Deposit dialog over the dashboard, labeled Into CHECKING ending 4917, with an Amount (USD) field and Cancel and Deposit buttons.
The deposit dialog, scoped to one account, which funds it so a transfer has something to move.

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 Transfer money screen. A form moves funds from a CHECKING account ending 4917 ($3,200.00) to a destination account id, with an amount of 750. Below it a card shows a COMPLETED transfer of $750.00 with a HISTORY listing REQUESTED then COMPLETED, each with a timestamp.
A $750.00 transfer settled to COMPLETED, with the audit history audit-service recorded from the event streams: REQUESTED, then COMPLETED, each timestamped.
The dashboard after the transfer. The CHECKING account ending 4917 now shows $2,450.00 and the SAVINGS account ending 7908 shows $750.00. A Recent transfers table lists one row from an account ending c35b to one ending 2b0c, amount $750.00, status COMPLETED.
After the transfer: checking dropped from $3,200.00 to $2,450.00, savings rose to $750.00, and the recent-transfers panel shows the completed movement.

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.

A client calls a gateway over REST; the gateway validates a JWT and routes to user-service, account-service, transaction-service, and audit-service. Each service has its own PostgreSQL database. A dashed asynchronous path runs from transaction-service to the Kafka transfer.commands topic, into account-service, back out to the transfer.events topic, into transaction-service, and into audit-service from both the transfer.commands and transfer.events topics.
Solid arrows are synchronous REST at the edge; dashed arrows are the asynchronous event path over Kafka. Only the gateway is reachable from outside, and every service validates the same token.

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.

Swimlanes for client, transaction-service, Kafka, and account-service. The client posts a transfer with an Idempotency-Key; transaction-service writes a PENDING transfer and a TransferRequested row to its outbox in one transaction and returns 202; a relay publishes the outbox row to the transfer.commands topic; account-service consumes it, locks both accounts, applies debit and credit in one atomic idempotent local transaction, and emits the result on transfer.events through its own outbox; transaction-service marks the transfer COMPLETED, or FAILED on insufficient funds. Three labels mark the outbox, the idempotent consumer, and the pessimistic lock.
The transfer saga in swimlanes. Three labels mark the mechanisms that keep it correct: the outbox, the idempotent consumer, and the pessimistic lock. The FAILED branch is the insufficient-funds path.

Reading the flow in order:

  • The client posts the transfer with an Idempotency-Key header. The gateway validates the JWT and forwards it to transaction-service.
  • transaction-service writes a PENDING transfer and a TransferRequested event into its outbox in one database transaction, then returns 202 Accepted with the transfer id and a status URL.
  • A relay reads unsent outbox rows and publishes the TransferRequested event to the transfer.commands topic.
  • 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 TransferCompleted or TransferFailed on the transfer.events topic through its own outbox.
  • transaction-service consumes the result and marks the transfer COMPLETED, or FAILED on insufficient funds.
  • audit-service is a separate consumer reading both transfer.commands and transfer.events, so it records the REQUESTED entry and the outcome entry independently.
  • The client polls the status URL and reads COMPLETED or FAILED.

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 sub claim, 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:8080

From 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 trail

Two 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.

Back to Blog