Core ledger service for a retail bank portfolio: double-entry money movement, JWT auth, distributed transfer rate limits on Redis, and domain events on Kafka via a transactional outbox.
Stack: Java 17 · Spring Boot 3.3 · PostgreSQL 16 · Redis 7 · Apache Kafka · Flyway · Spring Security (JWT) · springdoc OpenAPI · Testcontainers
Portfolio / teaching service. Not a licensed bank, not PCI, not a payment rail.
- What you get
- Architecture
- Domain rules
- Redis rate limiting
- Kafka domain events (outbox)
- HTTP API
- Run locally
- Configuration
- Tests & CI
- Project layout
- Docs & related
| Area | Behaviour |
|---|---|
| Auth | Register / login, HMAC JWT, stateless security filter chain |
| Accounts | Open (HOUSE-funded balanced journal), statement, freeze / unfreeze / close |
| Transfers | Double-entry post + compensating reverse, Idempotency-Key per user |
| Concurrency | Pessimistic locks, always lock lower account id first (deadlock avoidance) |
| Integrity | Append-only ledger + audit (Postgres triggers), debit=credit reconciliation |
| Rate limit | Redis Lua token bucket on transfer and reverse; fail-closed if Redis is down |
| Messaging | Transactional outbox → OutboxRelay → Kafka banking.domain-events |
| Ops | Flyway V1–V5, X-Request-Id, security headers, /actuator/health (DB + Redis), Swagger UI |
Intentionally not built: FX conversion, multi-tenant RBAC, cards/loans/interest, refresh-token store, schema registry / multi-region Kafka, KYC-AML engines.
Deep dive diagrams and ER: docs/architecture.md · docs/uml.md
Request path and side effects:
flowchart LR
Client -->|JWT| Auth[AuthController]
Client -->|JWT| Acc[AccountController]
Client -->|JWT + Idempotency-Key| Trf[TransferController]
Auth --> Users[(app_users)]
Acc --> Accounts[(accounts)]
Acc --> Ledger[(ledger_entries)]
Acc --> Outbox[(outbox_events)]
Trf --> RL[RateLimitFilter]
RL --> Redis[(Redis)]
RL --> TrfSvc[TransferService]
TrfSvc --> Accounts
TrfSvc --> Transfers[(transfers)]
TrfSvc --> Ledger
TrfSvc --> Outbox
Outbox --> Relay[OutboxRelay]
Relay --> Kafka[Kafka banking.domain-events]
Transfer happy path (business TX then async publish):
sequenceDiagram
actor User
participant API as TransferController
participant RL as RateLimitFilter
participant Svc as TransferService
participant DB as PostgreSQL
participant Relay as OutboxRelay
participant K as Kafka
User->>API: POST /api/transfers + Idempotency-Key
API->>RL: Redis tryAcquire(user)
RL->>Svc: transfer(...)
Svc->>DB: FOR UPDATE accounts (lower id first)
Svc->>DB: debit/credit + transfer + 2 ledger + outbox row
Svc-->>API: TransferResponse
API-->>User: 201 Created
Note over Relay,K: after commit — two-phase relay
Relay->>DB: claim + claimed_at (short TX, SKIP LOCKED)
Relay->>K: produce outside DB lock (key=aggregateId)
Relay->>DB: published_at or clear claim + attempts
- Debit / credit only while a customer account is
ACTIVE.FROZENandCLOSEDreject money movement. - Opening: HOUSE funding account is DEBITed, customer is CREDITed, shared
journal_idso the journal balances (Σ DEBIT = Σ CREDIT). - Transfer / reverse: always two ledger lines; reverse posts a compensating journal — existing ledger rows are never updated.
- Close requires zero balance under row lock (
FOR UPDATEon freeze / unfreeze / close). - Freeze / unfreeze / close are owner-only.
- Transfer source must belong to the authenticated user (destination may be another customer account the caller can credit into in this model).
- Account
balanceis Σ CREDIT − Σ DEBIT; global checks assert total DEBIT amounts = total CREDIT amounts. - Repeat the same
Idempotency-Keyfor the same user → same transfer response, no second money move, no second outbox event.
In-memory buckets only protect one JVM. Two instances would allow double traffic. Write paths share one Redis budget.
| Detail | Value |
|---|---|
| Implementation | RedisTokenBucketRateLimiter (Lua) + RateLimitFilter after JWT |
| Protected | POST /api/transfers, POST /api/transfers/{id}/reverse |
| Key | user:{username} when authenticated; else ip:{remote} |
| Defaults | capacity 10, full refill over 1 minute (banking.ratelimit.transfer.*) |
| Redis unavailable | fail-closed → HTTP 503 rate_limit_unavailable (set banking.ratelimit.fail-mode=OPEN only if you accept an open window) |
| Over limit | HTTP 429 rate_limit_exceeded |
| When tokens are taken | On filter entry (before controller validation). 4xx/idempotent replays still consume a token — this is abuse control, not a successful-only quota. |
| Error body | Same shape as API errors: {"code","message","timestamp"} |
Publishing Kafka after a successful money commit without an outbox loses events on crash. Publishing inside the money TX couples the ledger to broker availability. This service uses a classic transactional outbox with a two-phase relay (claim without holding locks during Kafka I/O):
- Business code writes ledger / account / audit and inserts
outbox_eventsin the same Postgres TX. OutboxRelayshort TX: claim rows (claimed_at) withFOR UPDATE SKIP LOCKED(multi-instance safe; stale claims reclaimable).- Outside DB TX: produce to topic
banking.domain-events. - Short TX: mark
published_aton success, or clear claim and bumppublish_attemptson failure. - Delivery is at-least-once — consumers de-duplicate on header
eventId. - Poison rows (hit
max-publish-attempts) are logged periodically byOutboxPoisonMonitor.
Schema: Flyway V4__outbox_and_messaging.sql,
claim column in V5__outbox_claim.sql.
eventType |
Aggregate (Kafka key) | Raised when |
|---|---|---|
ACCOUNT_OPENED |
account id | Customer account created |
ACCOUNT_FROZEN |
account id | Owner freezes |
ACCOUNT_UNFROZEN |
account id | Owner unfreezes |
ACCOUNT_CLOSED |
account id | Owner closes (zero balance) |
TRANSFER_POSTED |
transfer id | New transfer (not on idempotent replay) |
TRANSFER_REVERSED |
original transfer id | Compensating reversal (same partition stream as POSTED) |
| Field | Meaning |
|---|---|
| Topic | banking.domain-events (default) |
| Key | aggregateId — partition stream per account / transfer lifecycle |
| Value | JSON body (DomainEventPayloads; reverse payload includes both reversal + original ids) |
Header eventId |
Outbox UUID — consumer idempotency key |
Header eventType |
See table above |
Header aggregateType / aggregateId |
Correlation |
Header requestId |
Optional X-Request-Id captured at write time |
An in-process DomainEventLoggingConsumer only logs — proof the path works. Real downstream
services (AML, cards, recon) should use their own group.id.
Code lives under messaging/ (OutboxEventPublisher, OutboxRelay, payloads, config).
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/auth/register |
— | Create user, return JWT |
POST |
/api/auth/login |
— | Return JWT |
POST |
/api/accounts |
JWT | Open account (currency + opening balance) |
GET |
/api/accounts |
JWT | List caller’s customer accounts |
GET |
/api/accounts/{id} |
JWT | Get one owned account |
GET |
/api/accounts/{id}/statement |
JWT | Ledger lines |
POST |
/api/accounts/{id}/freeze |
JWT | Block transfers |
POST |
/api/accounts/{id}/unfreeze |
JWT | Back to ACTIVE |
POST |
/api/accounts/{id}/close |
JWT | Close when balance is zero |
POST |
/api/transfers |
JWT + Idempotency-Key |
Double-entry transfer · rate limited · outbox |
POST |
/api/transfers/{id}/reverse |
JWT + Idempotency-Key |
Compensating reverse · rate limited · outbox |
GET |
/api/transfers/{id} |
JWT | Transfer the caller initiated |
GET |
/actuator/health |
— | DB + Redis (Kafka health indicator disabled; money paths stay up while outbox retries) |
| UI | /swagger-ui.html |
— | OpenAPI when the app is up |
- JDK 17+
- Docker (Compose for infra; Docker daemon also required for
mvn verify)
docker compose up --build| Service | Host port | Notes |
|---|---|---|
| API | 8080 |
Spring Boot |
| Postgres | 5432 |
DB retail_banking, user/pass banking |
| Redis | 6379 |
Rate limit |
| Kafka | 9092 |
Host clients use localhost:9092; app container uses kafka:29092 |
Swagger: http://localhost:8080/swagger-ui.html
docker compose up -d postgres redis kafka
./mvnw spring-boot:run # Linux / macOS
mvnw.cmd spring-boot:run # WindowsCompose advertises both:
kafka:29092— containers on the Compose networklocalhost:9092— processes on your machine
Set KAFKA_BOOTSTRAP_SERVERS to match how you run the API (the compose app service is already set).
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","email":"[email protected]","password":"correct-horse-battery"}' \
| jq -r .accessToken)
FROM=$(curl -s -X POST http://localhost:8080/api/accounts \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"currency":"USD","openingBalance":500.00}' | jq -r .id)
TO=$(curl -s -X POST http://localhost:8080/api/accounts \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-d '{"currency":"USD","openingBalance":0}' | jq -r .id)
curl -s -X POST http://localhost:8080/api/transfers \
-H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: demo-transfer-1" \
-d "{\"fromAccountId\":\"$FROM\",\"toAccountId\":\"$TO\",\"amount\":150.00,\"currency\":\"USD\"}"
curl -s http://localhost:8080/actuator/health | jqReplay the transfer with the same Idempotency-Key — balances stay put; no second Kafka event.
Optional: watch the topic (Compose Kafka):
docker compose exec kafka kafka-console-consumer.sh \
--bootstrap-server localhost:9092 \
--topic banking.domain-events \
--from-beginning \
--property print.headers=trueMain knobs (see application.yml for the rest):
| Property / env | Default | Purpose |
|---|---|---|
BANKING_JWT_SECRET |
dev-only fallback | HMAC key — use a real 32+ byte secret outside local dev |
banking.jwt.expiration-minutes |
60 |
JWT lifetime |
REDIS_HOST / REDIS_PORT |
localhost / 6379 |
Redis |
KAFKA_BOOTSTRAP_SERVERS |
localhost:9092 |
Kafka brokers |
banking.ratelimit.fail-mode |
CLOSED |
CLOSED → 503 if Redis down; OPEN allows traffic |
banking.ratelimit.key-prefix |
rl:transfer |
Redis key namespace |
banking.ratelimit.transfer.capacity |
10 |
Tokens per user bucket |
banking.ratelimit.transfer.refill-minutes |
1 |
Minutes for full refill |
banking.messaging.domain-events-topic |
banking.domain-events |
Outbox topic |
banking.messaging.relay-enabled |
true |
Scheduled relay ticks |
banking.messaging.relay-fixed-delay-ms |
500 |
Poll interval |
banking.messaging.relay-batch-size |
50 |
Max rows claimed per tick |
banking.messaging.max-publish-attempts |
25 |
Stop retrying failed publishes after N attempts |
banking.messaging.claim-stale-seconds |
60 |
Reclaim stalled claims after crash |
Local datasource default: jdbc:postgresql://localhost:5432/retail_banking (banking / banking).
Two suites on purpose (Failsafe keeps *IT out of Surefire):
./mvnw test # unit tests — no Docker
./mvnw verify # unit + integration — Docker required| Suite | Covers |
|---|---|
Unit (*Test) |
Account invariants, TransferService mocks (idempotency, lock order, ownership), JWT |
IT (*IT) |
Happy-path transfer, concurrency, lifecycle, reconciliation, reversal, Redis rate limit (create + reverse), fail-closed Redis, outbox → Kafka |
ITs start Postgres + Redis + Kafka once via Testcontainers (PostgresIntegrationSupport). The
scheduled outbox relay is off in ITs so tests call OutboxRelay#processBatch() without races.
GitHub Actions (.github/workflows/ci.yml) runs ./mvnw -B verify on every push / PR to main.
src/main/java/com/mehmetserin/banking/
account/ accounts, HOUSE funding, statement
auth/ register / login
audit/ append-only audit trail
config/ security filter chain, headers
common/ request id, exceptions
messaging/ outbox, relay, Kafka, domain event payloads
ratelimit/ Redis token bucket + filter
security/ JWT
transfer/ transfers, ledger, reconciliation
user/ app users
src/main/resources/
application.yml
db/migration/ Flyway V1 … V5 (V4 outbox, V5 claimed_at)
src/test/java/
support/PostgresIntegrationSupport.java
messaging/OutboxMessagingIT.java
ratelimit/TransferRateLimitIT.java
transfer/*IT.java
docker-compose.yml
docs/architecture.md
docs/uml.md
| Doc | Content |
|---|---|
| docs/architecture.md | C4 context/container, components, packaging |
| docs/uml.md | Sequences, class diagram, ER |
/swagger-ui.html |
Live OpenAPI |
Related portfolio:
CardLifecycleApi ·
LoanOriginationApi ·
SwiftMt103Parser ·
BicIbanToolkit ·
dotnet-cicd-template
MIT — see LICENSE.