Skip to content

Architecture Decision Records (ADRs) ​

Architecture decision records follow the format Problem → Choice → Rationale → Trade-offs. ADR-002 and ADR-006 → ADR-013 are decisions already applied in Core Banking (Phase 1), including the real technical issues met during implementation. ADR-001, ADR-004 and ADR-005 are planned decisions for the Payment Gateway (Phase 2) and will be updated once coding starts. Detailed progress is in the Devlog.


Decision matrix ​

ADRTechnical problemDecisionAccepted trade-off
ADR-001Managing distributed interbank transactionsSaga Orchestration (instead of Choreography)The orchestrator can become a bottleneck unless designed stateless
ADR-002How balance movements are recordedAppend-only double-entry ledgerledger_entries grows continuously over time
ADR-003Concurrency conflicts on hot accountsPessimistic lock + fixed lock orderingSlightly higher latency per individual account
ADR-004Preventing duplicate processing of resent messagesIdempotency-Key checked at the API layerNeeds a cleanup/expiry policy for the idempotency key table
ADR-005End-of-day (EOD) reconciliation & settlementSpring Batch chunk-oriented stepMore complex than a simple Tasklet, chunk size needs tuning
ADR-006Storing the account balanceNo balance column; derived from SUM(CREDIT) - SUM(DEBIT) on the ledgerSUM() cost on every call; consider snapshots as the system grows
ADR-007Check-then-act race condition on concurrent withdrawalsPessimistic locking (SELECT ... FOR UPDATE) instead of optimistic lockingLower throughput on a single account; lock timeout needed when scaling
ADR-008The Debit=Credit invariant lived in the service layer, outside any entityTransaction aggregate root — LedgerEntry can only be created via Transaction.record()New transactions table + backfill migration; entity uses Persistable<UUID> because the ID is application-assigned
ADR-009Dual-write risk when writing the DB and sending to Kafka in the same flowOutbox Pattern — events stored in outbox_events in the same DB transaction, published by a separate @Scheduled processAt-least-once delivery, requires an idempotent consumer (processed_events)
ADR-010getBalance() recomputes SUM() over the whole ledger on every callRedis cache-aside for getBalance(), evicted via @TransactionalEventListener(AFTER_COMMIT); withdraw() always reads the DB, never the cacheMore complexity (event + listener) than direct eviction; 3600s TTL for reads not covered by eviction
ADR-011Proving the system runs on an enterprise database (Oracle) without breaking the existing Postgres testsSpring dual profile — 2 migration sets split by vendor, one codebaseTwo migration sets to maintain, higher long-term maintenance cost
ADR-012Oracle NUMBER does not keep a fixed scale on read; ISO 4217 VND = 0 decimals conflicts with the existing domain modelMoney enforces a fixed scale=2 for every currency (setScale(2, RoundingMode.UNNECESSARY)), not strict ISO 4217Must be revisited for currencies with other native fraction digits (e.g. JPY=0, BHD=3)
ADR-013Verifying pessimistic locking works with several app instances in parallelDeploy the app to K8s (3 replicas), keep the data layer in docker-compose; verify via real HTTP + per-Pod logsOutboxEventPublisher has no distributed lock — the 3 PoC consumers may log duplicates when several Pods read the same event

Simulated Banking & Fintech Systems — Java & Spring Boot