Design-First Banking API: From 5-Domain Requirements to PoC Validation and a Production Roadmap
What You’ll Learn
- How to handle non-negotiable constraints in design across 5 domains: accounts, loans, forex, investments, and KYC
- How to enforce domain boundaries with a modular monolith (including ArchUnit)
- Financial-grade implementation combining BigDecimal, pessimistic locking, Transactional Outbox, and idempotency keys
- What the PoC revealed and how those findings connect to the next phase
Target Audience
- Developers interested in API design and implementation for financial/fintech systems
- Those who want to practice DDD / domain model design
- Backend engineers who want to deepen their understanding of the design rationale behind Spring Boot + Java
Environment
| Item | Version |
|---|---|
| Java | 21 (Virtual Threads) |
| Spring Boot | 3.x |
| PostgreSQL | 16 |
| Redis | 7 |
| Build Tool | Maven (multi-module) |
| Container | Docker Compose |
Introduction
When implementing a banking system, the first challenge isn’t the breadth of features — it’s how to handle the areas where failure is not an option.
- No tolerance for calculation errors
- No tolerance for duplicate execution
- Auditability must not be compromised
- Must withstand future splitting or expansion
This article walks through the process of building a banking API across 5 domains — accounts, loans, forex, investments, and KYC — structured around 3 design phases.
The backbone is:
- Requirements definition
- Architectural design
- Detailed design
We then ran that design as a PoC to see what could be validated, and where the next phase’s challenges lay — presented as a roadmap.
The goal is not a “look what I built” post, but a technical explanation grounded in design rationale.
📌 Requirements: Constraints Fixed Upfront Across 5 Domains
Overall Policy
The first thing decided in requirements was not a feature list, but the constraints that must never be violated.
- Financial calculations must be precise and consistent
- Invalid state transitions must be rejected at the domain level
- Critical operations must be auditable
- Async integration must not break consistency
These policies were applied across 5 domains.
1. Account Management Domain
Actors
| Actor | Description |
|---|---|
| Depositor | Account holder. Performs balance inquiries, deposits/withdrawals, and transfers |
| Bank Teller | Operator handling account opening, closing, and status changes |
| Auto-debit Batch | System processing scheduled deductions |
Operation Restrictions by Account Status
Accounts have 4 statuses, each with different permitted operations.
| Status | Deposit | Withdrawal | Transfer | Notes |
|---|---|---|---|---|
| ACTIVE | ✅ | ✅ | ✅ | Normal use |
| FROZEN | ❌ | ❌ | ❌ | Fraud detection, identity verification in progress, etc. |
| DORMANT | ✅ | ❌ | ❌ | No transactions for a certain period |
| CLOSED | ❌ | ❌ | ❌ | Transitioned after confirming zero balance |
Status Transitions
[Account Opening]
│
▼
ACTIVE ◄──────────────────────────────────┐
│ │
┌────────┼────────────────┐ │
│ │ │ [Identity Verified]
[AML Auto [No activity [Zero balance + (FROZEN_MANUAL only)
detect/ 2 years] closure request]
freeze] │ │
│ CLOSED
▼
DORMANT ──────────────────────────────►ACTIVE
(deposit only) [Branch request + ID verification]
│
▼
FROZEN ──────────────────────────────────────────►ACTIVE
(no ops) [FROZEN_MANUAL: operator unfreeze]
[FROZEN_AML: compliance officer only]
FROZEN has 2 subtypes with different unfreeze authorities.
| Freeze Type | Trigger | Unfreeze Authority |
|---|---|---|
FROZEN_MANUAL | Manual freeze by branch operator | Operator (after identity verification) |
FROZEN_AML | Automatic detection by AML system | Compliance officer only |
📝 Note: The core requirement here is how to protect a strongly-consistent value like balance, and whether operations can be halted immediately upon fraud detection.
2. Loan Domain
Actors
| Actor | Description |
|---|---|
| Loan Applicant | Customer applying for loans, running simulations, making repayments |
| Review Department | Operator reviewing applications and making approval/rejection decisions |
| Debt Management System | External system monitoring delays and calculating late charges |
Application Status Management
APPLIED
→ PRE_SCREENING
→ FULL_SCREENING
→ CONTRACTED ─── No execution within 30 days ───▶ EXPIRED
→ EXECUTED
→ REJECTED ← Can occur at either pre- or full-screening
→ WITHDRAWN ← Applicant can withdraw from APPLIED through FULL_SCREENING
- No backward state transitions allowed
- Reapplication after rejection is banned for 60 days (cooling-off period)
- A 30-day validity period from
CONTRACTEDtoEXECUTED; batch auto-transitions toEXPIREDif exceeded
Equal Principal + Interest (Level Payment) Calculation
Monthly payment $M$ is calculated as:
$$M = P \frac{r(1+r)^n}{(1+r)^n - 1}$$
- $P$: Principal amount
- $r$: Monthly rate (annual rate ÷ 12)
- $n$: Number of payments (months)
All calculations use BigDecimal with 30-digit scale for intermediate calculations. Final installment absorbs cumulative rounding errors.
Late Charge Calculation
$$\text{Late Charge} = \text{Outstanding Principal} \times \text{Late Charge Rate} \times \frac{\text{Days Overdue}}{365}$$
Day count convention is also explicitly defined.
| Item | Specification |
|---|---|
| Day count method | Exclusive start (start date excluded, end date included) |
| Days in year | Fixed 365 (leap years also divided by 365) |
| Basis | General banking practice. Consistent with Money Lending Business Act late charge calculation |
⚠️ Warning: Exclusive vs. inclusive start produces a 1-day difference in charges.
DayCountConventionis implemented as an enum to allow switching when product requirements change.
3. Forex Domain
Actors
| Actor | Description |
|---|---|
| Depositor | Customer buying/selling foreign currency and making forward reservations |
| Forex Dealer | Internal staff managing rate settings and spread configuration |
| External Market Data Provider | External service providing real-time exchange rates |
Spread Application and Calculation Direction
Forex calculation uses multiplication or division depending on currency pair convention (Base/Quote Currency).
| Direction | Example (USD/JPY=152) | Applied Rate | Formula |
|---|---|---|---|
| Buy FX (JPY → USD) | 1 USD = 152.50 JPY (Ask) | MidRate + Spread | SourceAmount ÷ AppliedRate |
| Sell FX (USD → JPY) | 1 USD = 151.50 JPY (Bid) | MidRate - Spread | SourceAmount × AppliedRate |
🚨 Alert: Mixing up calculation direction reverses profit and loss.
CurrencyPairis defined as an enum encapsulating multiplication/division to prevent errors.
Rounding by Currency (Bank Profit Perspective)
| Currency | BUY Rounding | SELL Rounding |
|---|---|---|
| JPY | CEILING (round up) | DOWN (truncate) |
| USD / EUR / GBP | CEILING | DOWN |
When customers buy foreign currency, rounding up means customers pay slightly more; when selling, truncating means customers receive slightly less — accumulating micro-profits for the bank.
Volatility Circuit Breaker (Auto-Suspend)
If a currency pair rate changes by 3% or more within 1 minute, new trades for that pair are automatically suspended. Default suspension duration is 5 minutes, configurable via volatility_circuit_breaker_settings table. Forex dealers can manually resume from the admin screen.
4. Investment Domain
Actors
| Actor | Description |
|---|---|
| Investor | Customer buying/redeeming investment trusts and viewing portfolios |
| Trust Bank | External institution handling execution, settlement date management, and NAV updates |
| Authority (Tax Authority Integration) | Tax processing for NISA tax-exempt slot usage and specific account P&L calculation |
Blind Pricing (Forward Pricing) Principle
Investment trust NAV is not determined at order time. The design accounts for this blind pricing.
- Orders accepted as amount specification (BUY) or unit specification / full redemption (SELL)
- “How many units” is calculated by batch after next business day NAV is confirmed, written back to
investment_orders.units - UI shows “estimated units” with a clear indication that confirmation is pending
Order status flow: PENDING (received) → ACCEPTED (funds reserved) → EXECUTED (units confirmed)
NISA Tax-Exempt Slot Management
Manages annual and lifetime caps for the new NISA introduced in 2024.
- Accumulation investment slot: JPY 1.2M/year
- Growth investment slot: JPY 2.4M/year
- Total lifetime investment slot: JPY 18M
The design also includes a “next-year restoration batch” that restores lifetime slots on January 1 of the year following redemption.
Average Acquisition Cost (Moving Average Method)
Acquisition cost total is updated on each additional purchase; partial redemption deducts “units sold × average acquisition cost just before sale.” This logic is encapsulated in domain methods Holding.addUnits() / Holding.removeUnits(), preventing direct external modification.
5. Auth / KYC Domain
Actors
| Actor | Description |
|---|---|
| Customer | User undergoing eKYC identity verification |
| KYC Reviewer | Internal staff performing document review, approval, and rejection |
| System | Batch processing for document expiry checks and automatic status updates |
eKYC Status Transitions
UNVERIFIED (initial)
→ DOCUMENTS_SUBMITTED
→ UNDER_REVIEW
→ VERIFIED
→ REJECTED
→ RESUBMISSION_REQUIRED
→ DOCUMENTS_SUBMITTED (resubmission)
API Access Control by Status
| KYC Status | Available Features |
|---|---|
VERIFIED | All features (transfers, forex, investments) |
UNDER_REVIEW | Balance inquiry and history only |
| Others | Basic inquiry only |
Auth/authorization design centers on where to draw boundaries, not on convenience. The authorization flow (PAR / JARM / MTLS) is delegated to an external IdP (Keycloak / Auth0, etc.), while the API server focuses solely on token validation.
🏗️ Architectural Design: Enforcing Boundaries with a Modular Monolith
Why Modular Monolith
The goal at this stage was to maintain development velocity in a single process while preserving future separability.
The adopted structure is module separation by domain:
- common
- auth
- accounts
- loans
- forex
- investments
- app
This allows operating as a single process initially while making dependency boundaries explicit.
API Endpoint Design by Domain
Account Management API
| Method | Path | Description |
|---|---|---|
GET | /v1/accounts/{accountId}/balance | Real-time balance retrieval |
GET | /v1/accounts/{accountId}/transactions | Transaction history (cursor pagination) |
POST | /v1/accounts/{accountId}/withdraw | Withdrawal ⚠️ X-Idempotency-Key required |
POST | /v1/accounts/{accountId}/deposit | Deposit ⚠️ X-Idempotency-Key required |
POST | /v1/accounts/{accountId}/transfer | Transfer ⚠️ X-Idempotency-Key required |
GET | /v1/accounts/{accountId} | Account information retrieval |
All POST endpoints require X-Idempotency-Key header. Re-requests with the same key return the original response (idempotency guarantee).
Transaction history uses cursor pagination.
GET /v1/accounts/{accountId}/transactions?limit=20&cursor=txn_20260502_000099&from=2026-04-01&to=2026-05-01
{
"transactions": [...],
"nextCursor": "txn_20260430_000078",
"hasMore": true
}
Loan API
| Method | Path | Description |
|---|---|---|
POST | /v1/loans/simulations | Repayment simulation (amortization schedule) |
POST | /v1/loans/applications | Loan application |
GET | /v1/loans/applications/{applicationId} | Application status check |
GET | /v1/loans/contracts/{contractId} | Contract information retrieval |
GET | /v1/loans/contracts/{contractId}/schedule | Repayment schedule retrieval |
POST | /v1/loans/contracts/{contractId}/repayments | Scheduled repayment execution (monthly batch) |
POST | /v1/loans/contracts/{contractId}/prepayments | Prepayment |
The simulation API returns an amortization schedule in the response.
// Request
POST /v1/loans/simulations
{
"principal": 3000000,
"annualInterestRate": 0.035,
"termMonths": 60
}
// Response
{
"monthlyPayment": 54687,
"totalPayment": 3281220,
"totalInterest": 281220,
"schedule": [
{ "installmentNo": 1, "paymentDate": "2026-06-27",
"principal": 46187, "interest": 8500, "balance": 2953813 }
]
}
Prepayment specifies prepaymentType (term-shortening / payment-reduction), and the entire repayment schedule is recalculated after execution.
Forex API
| Method | Path | Description |
|---|---|---|
GET | /v1/forex/rates | Current rate list for currency pairs |
GET | /v1/forex/quotes | Trade quote retrieval (QuoteID issuance) |
POST | /v1/forex/exchange | Exchange execution using QuoteID |
GET | /v1/forex/history | Forex transaction history retrieval |
A 2-step approach separating quote retrieval and execution is adopted.
GET /v1/forex/quotes?sourceCurrency=JPY&targetCurrency=USD&sourceAmount=100000
{
"quoteId": "QT-20260502-00001",
"sourceCurrency": "JPY", "targetCurrency": "USD",
"sourceAmount": 100000, "targetAmount": 657.89,
"rate": 152.00, "spread": 0.50, "appliedRate": 152.50,
"expiresAt": "2026-05-02T12:00:30Z"
}
QuoteID has a 30-second TTL managed in Redis. Single-use only; expired or reused quotes are rejected.
Investment API
| Method | Path | Description |
|---|---|---|
GET | /v1/investments/portfolio | Portfolio and P&L status retrieval |
GET | /v1/investments/funds | Available investment trust list |
GET | /v1/investments/funds/{fundCode} | Investment trust details and NAV history |
POST | /v1/investments/orders | Buy/sell order |
DELETE | /v1/investments/orders/{orderId} | Order cancellation (only before cutoff and while PENDING) |
GET | /v1/investments/orders/{orderId} | Order status check |
GET | /v1/investments/tax-exemptions | NISA slot usage confirmation |
Order cancellation is not permitted after the cutoff time (15:00 JST) or when status is ACCEPTED or later.
Auth / KYC API
| Method | Path | Description |
|---|---|---|
GET | /api/v1/kyc/me | Own KYC status inquiry |
POST | /api/v1/kyc/submit | Document submission (status transition) |
GET | /api/v1/kyc/{customerId} | Reviewer: customer KYC inquiry |
PUT | /api/v1/kyc/{customerId}/review | Reviewer: update review result |
Data Model Design: ER Diagrams by Domain
Account Management
┌─────────────────────────────┐
│ accounts │
├─────────────────────────────┤
│ account_id VARCHAR PK │
│ customer_id VARCHAR FK │
│ account_type VARCHAR │ (ORDINARY / SAVINGS / CURRENT)
│ status VARCHAR │ (ACTIVE / FROZEN / DORMANT / CLOSED)
│ balance DECIMAL │
│ currency CHAR(3) │
│ last_transaction_at TIMESTAMPTZ │
└─────────────────────────────┘
┌─────────────────────────────┐
│ transactions │ ※ append-only (no UPDATE/DELETE)
├─────────────────────────────┤
│ transaction_id VARCHAR PK │
│ account_id VARCHAR FK │
│ transaction_type VARCHAR │ (DEPOSIT / WITHDRAWAL / TRANSFER_IN / TRANSFER_OUT)
│ amount DECIMAL │
│ balance_after DECIMAL │ (post-transaction balance snapshot)
│ transfer_id VARCHAR │ (for transfers: links both records with same ID)
│ executed_at TIMESTAMPTZ│
└─────────────────────────────┘
┌─────────────────────────────┐
│ idempotency_keys │
├─────────────────────────────┤
│ idempotency_key VARCHAR PK │
│ response_body JSONB │
│ status_code INTEGER │
│ expires_at TIMESTAMPTZ│ (created_at + 24h)
└─────────────────────────────┘
📝 Note:
transactionsis append-only. Storing the post-transaction balance asbalance_aftersnapshot allows balance inquiry at any point without recalculation.
Loan
┌─────────────────────────────┐
│ loan_applications │
├─────────────────────────────┤
│ application_id VARCHAR PK │
│ customer_id VARCHAR FK │
│ principal DECIMAL │
│ annual_rate DECIMAL │
│ term_months INTEGER │
│ status VARCHAR │ (APPLIED/.../EXECUTED/REJECTED/EXPIRED)
└─────────────────────────────┘
┌─────────────────────────────┐
│ loan_contracts │
├─────────────────────────────┤
│ contract_id VARCHAR PK │
│ outstanding DECIMAL │ (remaining balance)
│ accrued_late_charge DECIMAL │ (unpaid late charges updated by daily batch)
│ contract_expires_at TIMESTAMPTZ │ (CONTRACTED → EXECUTED validity period)
└─────────────────────────────┘
┌─────────────────────────────┐
│ repayment_schedules │
├─────────────────────────────┤
│ contract_id VARCHAR FK │
│ installment_no INTEGER │
│ due_date DATE │
│ principal DECIMAL │
│ interest DECIMAL │
│ balance_after DECIMAL │
│ status VARCHAR │ (PENDING/PAID/OVERDUE)
│ schedule_version INTEGER │ (incremented +1 on each prepayment recalculation)
└─────────────────────────────┘
Amortization schedules after prepayment are version-managed by incrementing schedule_version +1. Old versions are retained for audit purposes without deletion.
Forex
┌─────────────────────────────┐
│ quote_records │ (persistent QuoteStatus management)
├─────────────────────────────┤
│ quote_id VARCHAR PK │
│ currency_pair VARCHAR │
│ applied_rate DECIMAL │
│ status VARCHAR │ (PENDING / USED / EXPIRED)
│ expires_at TIMESTAMPTZ │
└─────────────────────────────┘
┌─────────────────────────────┐
│ forex_transactions │
├─────────────────────────────┤
│ source_currency CHAR(3) │
│ target_currency CHAR(3) │
│ source_amount DECIMAL │
│ target_amount DECIMAL │
│ direction VARCHAR │ (BUY / SELL)
│ quote_id VARCHAR FK │
└─────────────────────────────┘
┌─────────────────────────────┐
│ spread_settings │
├─────────────────────────────┤
│ currency_pair VARCHAR │
│ min_amount DECIMAL │
│ max_amount DECIMAL │
│ spread DECIMAL │
└─────────────────────────────┘
Investment
┌─────────────────────────────┐
│ investment_orders │
├─────────────────────────────┤
│ order_type VARCHAR │ (BUY / SELL)
│ amount DECIMAL │ (amount specification; for BUY)
│ units DECIMAL │ (confirmed after execution; specified at order time for SELL)
│ estimated_units DECIMAL │ (estimated units for blind pricing; for UI display)
│ nav_at_order DECIMAL │ (NAV at execution time)
│ status VARCHAR │ (PENDING/ACCEPTED/EXECUTED/CANCELLED)
│ cancellable_until TIMESTAMPTZ │ (cancellation deadline: same-day 15:00 JST)
└─────────────────────────────┘
┌─────────────────────────────┐
│ holdings │
├─────────────────────────────┤
│ units DECIMAL │
│ acquisition_cost DECIMAL │ (total acquisition cost: updated by moving average method)
└─────────────────────────────┘
┌─────────────────────────────┐
│ tax_exempt_slots │
├─────────────────────────────┤
│ slot_type VARCHAR │ (NISA_ACCUMULATE/NISA_GROWTH/LIFETIME)
│ year INTEGER │
│ used_amount DECIMAL │
│ limit_amount DECIMAL │
└─────────────────────────────┘
Common (Audit / KYC)
┌─────────────────────────────┐
│ audit_logs │
├─────────────────────────────┤
│ trace_id VARCHAR │
│ operator_id VARCHAR │
│ operation_type VARCHAR │ (INSERT/UPDATE/DELETE/API_CALL)
│ resource_type VARCHAR │ (table name or API path)
│ before_value JSONB │ (value before change)
│ after_value JSONB │ (value after change)
│ occurred_at TIMESTAMPTZ │
└─────────────────────────────┘
┌─────────────────────────────┐
│ kyc_verifications │
├─────────────────────────────┤
│ kyc_id VARCHAR PK │
│ customer_id VARCHAR FK │
│ status VARCHAR │ (KycStatus enum)
│ document_type VARCHAR │ (PASSPORT / DRIVING_LICENSE / MY_NUMBER)
│ verified_at TIMESTAMPTZ │
│ expires_at TIMESTAMPTZ │
│ rejected_reason VARCHAR │
└─────────────────────────────┘
Enforcing Design Rules at CI Time, Not Runtime, with ArchUnit
Verbal agreements get broken. So the following were made auto-verifiable:
- domain must not depend on application
- domain must not depend on api
- domain must not depend on infrastructure
- application must not depend on Controller
- Controllers placed under api package
- Services placed under application package
- Repositories placed under domain.repository package
This corrects implementation drift through tests.
Common Infrastructure Design Intent
Transactional Outbox
The most dangerous failure in async integration is a mismatch between DB update success and event publication failure.
So domain updates and outbox writes are bundled in the same transaction, with delivery handled by a separate process.
- Writes succeed atomically
- Delivery is retryable
This structure serves as a connection point for future migration to Kafka or similar.
Audit and Traceability
Physical separation and async processing of audit logs are also part of the design.
Each Service
│ ApplicationEvent publication (no impact on sync processing)
▼
AuditEventPublisher (async)
│ Push to Kafka / Amazon Kinesis
▼
Audit Log Consumer
├──▶ audit_logs DB (PostgreSQL separate instance)
└──▶ DWH / S3 (long-term storage / analysis)
- Request-level tracking via TraceId
- Audit logs record operator ID, timestamp, target table, and before/after values (JSON)
- No PII output to logs (only
customerIdUUID; personal data retrieved from DB)
💻 Detailed Design & Implementation: Financial Quality in Code
Value Object: Money
Money is defined as an immutable record as the foundation for financial calculations.
public record Money(BigDecimal amount, Currency currency) {
public Money {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Amount must be zero or greater");
}
}
public Money add(Money other) {
assertSameCurrency(other);
return new Money(this.amount.add(other.amount), this.currency);
}
public Money subtract(Money other) {
assertSameCurrency(other);
if (this.amount.compareTo(other.amount) < 0) {
throw new IllegalArgumentException("Subtraction result would be negative");
}
return new Money(this.amount.subtract(other.amount), this.currency);
}
/**
* JPY uses HALF_DOWN (truncate), others use HALF_UP.
*/
public Money multiply(BigDecimal factor) {
RoundingMode mode = "JPY".equals(currency.getCurrencyCode())
? RoundingMode.DOWN : RoundingMode.HALF_UP;
int scale = "JPY".equals(currency.getCurrencyCode()) ? 0 : 2;
return new Money(
this.amount.multiply(factor).setScale(scale, mode), this.currency);
}
}
BigDecimal Operations and Pessimistic Locking
Balance updates use pessimistic locking (SELECT FOR UPDATE).
Optimistic locking is also possible, but allowing retries on a financially critical value like balance increases code complexity and the risk of unexpected retry side effects. Pessimistic locking guarantees 1 request = 1 commit.
The withdrawal flow sequence:
Client
│
▼ AccountWithdrawService.withdraw(accountId, amount, idempotencyKey)
│
├─ [1] idempotencyKeyRepository.find(idempotencyKey)
│ └─ If exists: return stored response as-is
│
├─ [2] @Transactional begin
│
├─ [3] accountRepository.findByIdForUpdate(accountId)
│ └─ SELECT ... FOR UPDATE (acquire row lock)
│
├─ [4] dailyCacheRepository.findForUpdate(accountId, today)
│ └─ Retrieve and lock today's accumulated withdrawal amount
│
├─ [5] account.withdraw(amount, todayAccumulated, limitPolicy)
│ └─ Validate in order: status → limit → balance
│
├─ [6] transactionRepository.save(newTransaction)
│ └─ INSERT (balance_after = updated balance)
│
├─ [7] dailyCacheRepository.update(+amount)
│
├─ [8] accountRepository.save(account)
│
├─ [9] idempotencyKeyRepository.save(idempotencyKey, response)
│
└─ [10] @Transactional commit
Loan Calculation: Value Object: InterestRate
public record InterestRate(BigDecimal annualRate) {
/** Returns monthly rate. Intermediate calculations use 30-digit scale. */
public BigDecimal monthlyRate() {
return annualRate.divide(BigDecimal.valueOf(12), 30, RoundingMode.HALF_UP);
}
/** Daily rate (used for late charge and first partial interest calculation) */
public BigDecimal dailyRate(int daysInYear) {
return annualRate.divide(BigDecimal.valueOf(daysInYear), 30, RoundingMode.HALF_UP);
}
}
Level payment calculation separately computes the first partial interest (daily interest from disbursement date to first payment date).
// M = P * r * (1+r)^n / ((1+r)^n - 1) → 30-digit scale for intermediate, round to unit at end
BigDecimal r = rate.monthlyRate();
BigDecimal pow = rPlusOne.pow(termMonths).setScale(30, RoundingMode.HALF_UP);
BigDecimal monthlyPayment = principal.amount()
.multiply(r).multiply(pow)
.divide(pow.subtract(BigDecimal.ONE), 30, RoundingMode.HALF_UP)
.setScale(0, RoundingMode.HALF_UP);
Forex Calculation Logic
Multiplication/division is switched based on currency pair direction, encapsulated in the CurrencyPair enum.
public class ForexCalculator {
private static final int INTERMEDIATE_SCALE = 30;
public static BigDecimal calculate(
BigDecimal sourceAmount, BigDecimal midRate, BigDecimal spread,
CurrencyPair pair, TransactionDirection direction) {
BigDecimal appliedRate = switch (direction) {
case BUY -> midRate.add(spread); // Ask rate
case SELL -> midRate.subtract(spread); // Bid rate
};
// Switch multiply/divide based on currency pair direction (30-digit intermediate)
BigDecimal rawAmount = pair.convert(sourceAmount, appliedRate, direction, INTERMEDIATE_SCALE);
// Rounding by currency and direction (BUY=CEILING, SELL=DOWN)
int scale = CurrencyPrecision.getScale(pair.getTargetCurrency(direction));
RoundingMode rounding = CurrencyPrecision.getRoundingMode(direction);
return rawAmount.setScale(scale, rounding);
}
}
public enum CurrencyPair {
USD_JPY("USD", "JPY"), EUR_JPY("EUR", "JPY"), ...;
/** BUY (JPY→USD): divide SELL (USD→JPY): multiply */
public BigDecimal convert(BigDecimal amount, BigDecimal appliedRate,
TransactionDirection direction, int scale) {
return switch (direction) {
case BUY -> amount.divide(appliedRate, scale, RoundingMode.HALF_UP);
case SELL -> amount.multiply(appliedRate).setScale(scale, RoundingMode.HALF_UP);
};
}
}
Idempotency Keys
Idempotency keys are introduced for deposits/withdrawals, transfers, and orders to make retries safe.
- Re-sends with the same key return the same result
- Prevents double-counting
This is a critical implementation that simultaneously achieves higher availability and consistency guarantees.
Redis Rate Lock (QuoteStore)
In the forex domain, quote retrieval and execution are separated, with TTL set on quotes.
@Component
public class RedisQuoteStore {
private static final Duration QUOTE_TTL = Duration.ofSeconds(30);
public void save(ForexQuote quote) {
// Save to Redis (TTL=30 seconds)
redisTemplate.opsForValue().set("quote:" + quote.quoteId(), quote, QUOTE_TTL);
// Persist to DB with PENDING status (for customer support investigation)
quoteRecordRepository.insert(quote.toRecord(QuoteStatus.PENDING));
}
/** Retrieve Quote by QuoteID and delete from Redis (single-use guarantee) */
public ForexQuote getAndMarkUsed(String quoteId) {
ForexQuote quote = redisTemplate.opsForValue().getAndDelete("quote:" + quoteId);
if (quote == null) {
throw new QuoteExpiredException(quoteId);
}
quoteRecordRepository.updateStatus(quoteId, QuoteStatus.USED);
return quote;
}
}
Forex execution uses the Transactional Outbox pattern to ensure atomicity.
@Transactional
void executeExchange(String quoteId, String accountId) {
ForexQuote quote = quoteStore.getAndMarkUsed(quoteId); // 1. Get Quote, mark as USED
accountService.debit(accountId, quote.sourceAmount()); // 2. Debit from JPY account
outboxRepository.insert(new OutboxMessage( // 3. Record FX credit event
"ForeignCurrencyCredit", // within same transaction
accountId, quote.targetCurrency(), quote.targetAmount()
));
forexTransactionRepository.save(quote.toTransaction()); // 4. Save transaction history
// FX account credit executed asynchronously by Outbox Processor (max 3 retries)
}
⚠️ Warning: This eliminates the half-commit state of “JPY debit succeeded → FX credit failed.”
Why Java 21 / Spring Boot 3
The reason for adoption is not novelty but fit with the design.
- Java 21
- Express DTOs and VOs concisely and immutably with records (
Money,InterestRateimplemented as records) - Clarify state transition logic with switch expressions (BUY/SELL branching in
ForexCalculator, etc.)
- Express DTOs and VOs concisely and immutably with records (
- Spring Boot 3
- Alignment with module-separated API foundation
- Integrated operation of security, batch, and data access
- Virtual Threads
- Room for throughput improvement in I/O-bound API processing
Records in particular were effective for directly mapping design intent to code.
🚀 PoC Verification System
Why Vanilla HTML and nginx
The PoC’s purpose is not to build polished UI but to rapidly cycle through API contract verification.
So the following configuration was adopted:
- Docker Compose
- nginx
- External and internal screen separation
- Vanilla HTML and minimal JavaScript
The actual full system configuration:
┌──────────────────────────────────────────────────────┐
│ Developer Local Machine (single host) │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ Docker Compose Network (banklink-net) │ │
│ │ │ │
│ │ ┌────────────────┐ ┌────────────────────┐ │ │
│ │ │ External UI │ │ Internal UI │ │ │
│ │ │ (Nginx:8080) │ │ (Nginx:8081) │ │ │
│ │ └───────┬────────┘ └────────┬───────────┘ │ │
│ │ │ │ │ │
│ │ ▼ ▼ │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ banklink-app (8443) │ │ │
│ │ │ OAuth2 Resource Server │ │ │
│ │ │ /v1/accounts /v1/loans │ │ │
│ │ │ /v1/forex /v1/investments │ │ │
│ │ │ /api/v1/kyc │ │ │
│ │ └──────────────┬──────────────────────┘ │ │
│ │ │ │ │
│ │ ┌──────────┴───────────┐ │ │
│ │ ▼ ▼ │ │
│ │ ┌────────────┐ ┌──────────────────────┐ │ │
│ │ │ PostgreSQL │ │ Redis (6379) │ │ │
│ │ │ (5432) │ │ Idempotency / Cache │ │ │
│ │ └────────────┘ └──────────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────┐ │ │
│ │ │ Batch Runner (manual execution) │ │ │
│ │ │ - Order execution batch │ │ │
│ │ │ - NISA slot update batch │ │ │
│ │ │ - Late charge batch │ │ │
│ │ └────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────┘ │
│ │
│ Browser: http://localhost:8080 (External UI) │
│ Browser: http://localhost:8081 (Internal UI) │
└──────────────────────────────────────────────────────┘
The external UI is an API operation screen for general users; the internal UI is for bank staff like KYC reviewers. Both connect to the same API server but with separate access routes via nginx proxy, to verify the permission boundary in a real environment.
The advantage of this setup is that screen changes are immediately reflected and API testing turnaround is short.
External Client Screen (User-facing):

What Phase 1 Verified
- API connectivity for main APIs across 5 domains
- Basic operation of permission boundaries
- Foundational audit/trace verification
- Test cycling via dev auxiliary APIs
Operation Verification Screens (External UI)
Account balance inquiry and transaction history retrieval:

Repayment simulation (level payment, 60 installments):

Forex quote retrieval (JPY→USD, JPY 10,000):

Portfolio inquiry and NISA slot confirmation:

Operation Verification Screens (Internal UI / Bank Staff)
KYC review queue and review form:

Issues Revealed in Verification (Positive Scope Adjustments)
The gaps revealed by fact-checking can be organized not as failures but as intentional Phase 1 scope delineation.
- Loan scheduled repayment API exists on the design side but implementation is next-phase
- KYC path expressions have inconsistencies between implementation and proxy-routed notation
- External PoC screens focus on key operations; full UI coverage of all APIs not yet achieved
- Batch processing in Phase 1 partially uses log-centric implementation; business logic to be introduced incrementally
📝 Note: This intentionally separates what to validate in the PoC from what to refine for production readiness.
🗺️ Roadmap: Path to Production Readiness
Phase 2 — Security Hardening
- API Gateway / WAF introduction
- Auth infrastructure integration for production (Keycloak / Auth0)
- OpenAPI documentation and E2E strengthening
Goal: Raise boundary defense and operational viability.
Phase 3 — Full Async Integration
- Outbox relay in production operation
- Expanded inter-domain event integration (accounts ↔ loans ↔ investments fund flows)
- Documenting eventual consistency and retry strategies
Goal: Scale inter-module integration safely.
Phase 4 — Batch Processing for Business Operations
- Full implementation of scheduled loan repayment batch
- Full implementation of late charge calculation
- Full implementation of NISA annual update processing
- Anonymization batch (PII erasure for customers 10 years post-closure)
Goal: Automate reproducibility of daily/monthly operations.
Phase 5 — Migration to Production Configuration
- Migration to managed DB/cache
- Observability strengthening
- High availability and disaster recovery implementation
Goal: Elevate from development-convenient configuration to business-continuity-capable configuration.
Summary
The priority in this effort was design verifiability over implementation speed.
| Phase | What We Did |
|---|---|
| Requirements | Fix constraints first (document actors, state transitions, calculation conventions) |
| Architecture | Enforce boundaries with tests (ArchUnit, ER diagrams, API endpoint design) |
| Detailed Design | Translate financial quality to code (BigDecimal, pessimistic locking, Outbox, idempotency) |
| PoC | Visualize real gaps early |
| Next Phase | Connect gaps to roadmap rather than hiding as debt |
In high-reliability domains like banking, aiming for completion all at once is actually slower than hardening incrementally with clear rationale.
Design-first is not a detour — it is the shortest path to preserving changeability until the end.