Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Retail Banking Core

CI Java 17 Spring Boot 3.3 Postgres Redis Kafka

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.


Contents


What you get

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


Architecture

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]
Loading

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
Loading

Domain rules

  • Debit / credit only while a customer account is ACTIVE. FROZEN and CLOSED reject money movement.
  • Opening: HOUSE funding account is DEBITed, customer is CREDITed, shared journal_id so 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 UPDATE on 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 balance is Σ CREDIT − Σ DEBIT; global checks assert total DEBIT amounts = total CREDIT amounts.
  • Repeat the same Idempotency-Key for the same user → same transfer response, no second money move, no second outbox event.

Redis rate limiting

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"}

Kafka domain events (outbox)

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):

  1. Business code writes ledger / account / audit and inserts outbox_events in the same Postgres TX.
  2. OutboxRelay short TX: claim rows (claimed_at) with FOR UPDATE SKIP LOCKED (multi-instance safe; stale claims reclaimable).
  3. Outside DB TX: produce to topic banking.domain-events.
  4. Short TX: mark published_at on success, or clear claim and bump publish_attempts on failure.
  5. Delivery is at-least-once — consumers de-duplicate on header eventId.
  6. Poison rows (hit max-publish-attempts) are logged periodically by OutboxPoisonMonitor.

Schema: Flyway V4__outbox_and_messaging.sql, claim column in V5__outbox_claim.sql.

Event types

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)

Kafka record shape

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


HTTP API

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

Run locally

Prerequisites

  • JDK 17+
  • Docker (Compose for infra; Docker daemon also required for mvn verify)

Option A — everything in Docker

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

Option B — JVM on host, infra in Compose

docker compose up -d postgres redis kafka
./mvnw spring-boot:run      # Linux / macOS
mvnw.cmd spring-boot:run    # Windows

Kafka dual listeners

Compose advertises both:

  • kafka:29092 — containers on the Compose network
  • localhost:9092 — processes on your machine

Set KAFKA_BOOTSTRAP_SERVERS to match how you run the API (the compose app service is already set).

Example flow

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 | jq

Replay 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=true

Configuration

Main 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).


Tests & CI

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.


Project layout

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

Docs & related

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

License

MIT — see LICENSE.

About

Retail banking core: JWT, Postgres+Flyway, balanced double-entry (house funding), reversal, statement, audit, locks, idempotency, Testcontainers.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages