Tech Blog

Design-First Banking API: From 5-Domain Requirements to PoC Validation and a Production Roadmap

by Tech Writer
Java Spring Boot DDD Finance Design PostgreSQL Redis Docker ArchUnit

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

ItemVersion
Java21 (Virtual Threads)
Spring Boot3.x
PostgreSQL16
Redis7
Build ToolMaven (multi-module)
ContainerDocker 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:

  1. Requirements definition
  2. Architectural design
  3. 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

ActorDescription
DepositorAccount holder. Performs balance inquiries, deposits/withdrawals, and transfers
Bank TellerOperator handling account opening, closing, and status changes
Auto-debit BatchSystem processing scheduled deductions

Operation Restrictions by Account Status

Accounts have 4 statuses, each with different permitted operations.

StatusDepositWithdrawalTransferNotes
ACTIVENormal use
FROZENFraud detection, identity verification in progress, etc.
DORMANTNo transactions for a certain period
CLOSEDTransitioned 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 TypeTriggerUnfreeze Authority
FROZEN_MANUALManual freeze by branch operatorOperator (after identity verification)
FROZEN_AMLAutomatic detection by AML systemCompliance 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

ActorDescription
Loan ApplicantCustomer applying for loans, running simulations, making repayments
Review DepartmentOperator reviewing applications and making approval/rejection decisions
Debt Management SystemExternal 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 CONTRACTED to EXECUTED; batch auto-transitions to EXPIRED if 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.

ItemSpecification
Day count methodExclusive start (start date excluded, end date included)
Days in yearFixed 365 (leap years also divided by 365)
BasisGeneral banking practice. Consistent with Money Lending Business Act late charge calculation

⚠️ Warning: Exclusive vs. inclusive start produces a 1-day difference in charges. DayCountConvention is implemented as an enum to allow switching when product requirements change.


3. Forex Domain

Actors

ActorDescription
DepositorCustomer buying/selling foreign currency and making forward reservations
Forex DealerInternal staff managing rate settings and spread configuration
External Market Data ProviderExternal 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).

DirectionExample (USD/JPY=152)Applied RateFormula
Buy FX (JPY → USD)1 USD = 152.50 JPY (Ask)MidRate + SpreadSourceAmount ÷ AppliedRate
Sell FX (USD → JPY)1 USD = 151.50 JPY (Bid)MidRate - SpreadSourceAmount × AppliedRate

🚨 Alert: Mixing up calculation direction reverses profit and loss. CurrencyPair is defined as an enum encapsulating multiplication/division to prevent errors.

Rounding by Currency (Bank Profit Perspective)

CurrencyBUY RoundingSELL Rounding
JPYCEILING (round up)DOWN (truncate)
USD / EUR / GBPCEILINGDOWN

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

ActorDescription
InvestorCustomer buying/redeeming investment trusts and viewing portfolios
Trust BankExternal 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

ActorDescription
CustomerUser undergoing eKYC identity verification
KYC ReviewerInternal staff performing document review, approval, and rejection
SystemBatch 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 StatusAvailable Features
VERIFIEDAll features (transfers, forex, investments)
UNDER_REVIEWBalance inquiry and history only
OthersBasic 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

MethodPathDescription
GET/v1/accounts/{accountId}/balanceReal-time balance retrieval
GET/v1/accounts/{accountId}/transactionsTransaction history (cursor pagination)
POST/v1/accounts/{accountId}/withdrawWithdrawal ⚠️ X-Idempotency-Key required
POST/v1/accounts/{accountId}/depositDeposit ⚠️ X-Idempotency-Key required
POST/v1/accounts/{accountId}/transferTransfer ⚠️ 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

MethodPathDescription
POST/v1/loans/simulationsRepayment simulation (amortization schedule)
POST/v1/loans/applicationsLoan application
GET/v1/loans/applications/{applicationId}Application status check
GET/v1/loans/contracts/{contractId}Contract information retrieval
GET/v1/loans/contracts/{contractId}/scheduleRepayment schedule retrieval
POST/v1/loans/contracts/{contractId}/repaymentsScheduled repayment execution (monthly batch)
POST/v1/loans/contracts/{contractId}/prepaymentsPrepayment

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

MethodPathDescription
GET/v1/forex/ratesCurrent rate list for currency pairs
GET/v1/forex/quotesTrade quote retrieval (QuoteID issuance)
POST/v1/forex/exchangeExchange execution using QuoteID
GET/v1/forex/historyForex 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

MethodPathDescription
GET/v1/investments/portfolioPortfolio and P&L status retrieval
GET/v1/investments/fundsAvailable investment trust list
GET/v1/investments/funds/{fundCode}Investment trust details and NAV history
POST/v1/investments/ordersBuy/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-exemptionsNISA 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

MethodPathDescription
GET/api/v1/kyc/meOwn KYC status inquiry
POST/api/v1/kyc/submitDocument submission (status transition)
GET/api/v1/kyc/{customerId}Reviewer: customer KYC inquiry
PUT/api/v1/kyc/{customerId}/reviewReviewer: 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: transactions is append-only. Storing the post-transaction balance as balance_after snapshot 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 customerId UUID; 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, InterestRate implemented as records)
    • Clarify state transition logic with switch expressions (BUY/SELL branching in ForexCalculator, etc.)
  • 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):

External Client Screen

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:

Account API Verification

Repayment simulation (level payment, 60 installments):

Loan Repayment Simulation

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

Forex Quote Retrieval

Portfolio inquiry and NISA slot confirmation:

Investment & NISA Slot Confirmation

Operation Verification Screens (Internal UI / Bank Staff)

KYC review queue and review form:

KYC Review Screen (Internal UI)

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.

  1. Loan scheduled repayment API exists on the design side but implementation is next-phase
  2. KYC path expressions have inconsistencies between implementation and proxy-routed notation
  3. External PoC screens focus on key operations; full UI coverage of all APIs not yet achieved
  4. 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.

PhaseWhat We Did
RequirementsFix constraints first (document actors, state transitions, calculation conventions)
ArchitectureEnforce boundaries with tests (ArchUnit, ER diagrams, API endpoint design)
Detailed DesignTranslate financial quality to code (BigDecimal, pessimistic locking, Outbox, idempotency)
PoCVisualize real gaps early
Next PhaseConnect 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.

Feel free to send a message

Please send a message if you have any technical questions, feedback, or inquiries.