Skip to content

Latest commit

 

History

History
377 lines (290 loc) · 15.2 KB

File metadata and controls

377 lines (290 loc) · 15.2 KB

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":"alice@example.com","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.