Tech Blog

設計から入る銀行系API構築:5ドメインの要件定義からPoC検証、そして業務耐用へのロードマップ

Java Spring Boot DDD 金融 設計 PostgreSQL Redis Docker ArchUnit

この記事でわかること

  • 口座・ローン・外貨・資産運用・KYCの5ドメインで「先に固定すべき制約」を設計でどう扱うか
  • モジュラモノリスによるドメイン境界の強制方法(ArchUnit活用を含む)
  • BigDecimal・悲観的ロック・Transactional Outbox・冪等性キーを組み合わせた金融品質の実装
  • PoCとして動かして見えた課題と、次フェーズへの接続方法

対象読者

  • 金融・Fintech領域のAPI設計・実装に興味がある方
  • DDD / ドメインモデル設計を実践してみたい方
  • Spring Boot + Java でのバックエンド実装の設計根拠を深めたい方

動作環境

項目バージョン
Java21(Virtual Threads)
Spring Boot3.x
PostgreSQL16
Redis7
ビルドツールMaven(マルチモジュール)
コンテナDocker Compose

はじめに

銀行系システムを実装するとき、最初に直面するのは機能の多さではなく、失敗できない領域をどう設計で扱うかです。

  • 金額計算の誤差を許さない
  • 二重実行を許さない
  • 監査可能性を落とさない
  • 将来の分割や拡張に耐える

本記事では、口座・ローン・外貨・資産運用・KYCの5ドメインを対象に、設計の3フェーズを軸として銀行系APIを構築したプロセスを整理します。

背骨は次の順序です。

  1. 要件定義
  2. 基本設計
  3. 詳細設計

そして、その設計をPoCとして動かし、どこまで検証できて、どこが次フェーズの課題として見えたのかをロードマップで示します。

本稿の狙いは、作ってみた報告ではなく、設計根拠を伴う技術解説です。


📌 要件定義編:5ドメインで先に固定した制約

全体方針

要件定義で最初に決めたのは機能一覧ではなく、破ってはいけない制約です。

  • 金融計算は高精度で一貫すること
  • 状態遷移は不正遷移をドメインで拒否すること
  • 重要操作は監査可能であること
  • 非同期連携は整合性を壊さないこと

この方針を5ドメインに展開しました。


1. 口座管理ドメイン

アクター

アクター説明
預金者口座保有者。残高照会・入出金・振込を行う
銀行窓口口座開設・解約・状態変更を行うオペレーター
自動振替バッチ定期的な引き落とし処理を行うシステム

口座状態による操作制限

口座は4つの状態を持ち、状態ごとに許可操作が異なります。

状態入金出金振込備考
ACTIVE(有効)通常利用可能
FROZEN(凍結)不正検知・本人確認中等
DORMANT(休眠)一定期間取引なし
CLOSED(解約)残高0確認後に遷移

ステータス遷移

           [口座開設]


             ACTIVE ◄──────────────────────────────────┐
                │                                      │
       ┌────────┼────────────────┐                     │
       │        │                │                [窓口本人確認完了]
  [AML自動    [長期不取引     [残高0+解約申請]   (FROZEN_MANUAL のみ)
   検知/      2年経過]            │
   凍結申請]       │           CLOSED
       │        ▼
       │     DORMANT ─────────────────────────────────►ACTIVE
       │   (入金のみ可)  [窓口申請+本人確認書類確認]


    FROZEN ──────────────────────────────────────────►ACTIVE
   (全操作不可) [FROZEN_MANUAL: オペレーター解除]
               [FROZEN_AML: コンプライアンス担当者解除のみ]

FROZEN(凍結)には2種類あり、解除権限が異なります。

凍結種別凍結トリガー解除権限
FROZEN_MANUAL窓口オペレーターによる手動凍結オペレーター(本人確認後)
FROZEN_AMLAMLシステムによる自動検知コンプライアンス担当者のみ

📝 Note: ここでの要件上の本質は、残高という強整合が必要な値をどう守るか、と不正検知時に操作を即時停止できるかです。


2. ローンドメイン

アクター

アクター説明
借入申込者ローンの申込・シミュレーション・返済を行う顧客
審査部門申込内容を審査し、承認・否決を判定するオペレーター
債権管理システム遅延・延滞の監視・遅延損害金の算出を行う外部システム

審査ステータス管理

申込 (APPLIED)
  → 仮審査 (PRE_SCREENING)
    → 本審査 (FULL_SCREENING)
      → 契約 (CONTRACTED) ─── 30日以内に実行なし ───▶ 失効 (EXPIRED)
        → 実行 (EXECUTED)
      → 否決 (REJECTED)  ← 仮審査・本審査どちらでも発生しうる
  → 取下 (WITHDRAWN)     ← 申込〜本審査の間に申込者が取り下げ可能
  • 状態の逆戻りは禁止
  • 否決後の再申込は60日間禁止(冷却期間)
  • CONTRACTED から EXECUTED まで30日の有効期限を設け、超過時はバッチが自動で EXPIRED に遷移させる

元利均等返済の計算ロジック

毎月の返済額 $M$ は次の式で求めます。

$$M = P \frac{r(1+r)^n}{(1+r)^n - 1}$$

  • $P$: 借入元金
  • $r$: 月利(年利 ÷ 12)
  • $n$: 返済回数(月数)

全て BigDecimal で計算し、中間計算はスケール30桁を確保します。最終回のみ端数調整で累積誤差を吸収します。

遅延損害金の算出ロジック

$$遅延損害金 = 元金残高 \times 遅延損害金利率 \times \frac{遅延日数}{365}$$

日数計算の定義(Day Count Convention)も明文化しています。

項目本システムの仕様
日数カウント方式片端入れ(起算日は算入しない・最終日は算入する)
年間日数の扱い固定365日(閏年も365で割る)
根拠銀行実務の一般慣行。貸金業法上の遅延損害金上限計算と整合

⚠️ Warning: 片端入れと両端入れで損害金が1日分ずれます。商品要件変更時に切り替えられるよう、DayCountConvention を列挙型で実装しています。


3. 外貨ドメイン

アクター

アクター説明
預金者外貨の購入・売却・為替予約を行う顧客
為替ディーラーレートの設定・スプレッドの管理を行う内部担当者
外部マーケットデータプロバイダリアルタイムの為替レートを提供する外部サービス

スプレッドの適用と計算方向

為替計算は通貨ペアの表示慣習(Base Currency / Quote Currency)に従い、乗算・除算が異なります。

取引方向例(USD/JPY=152)適用レート計算式
外貨購入(JPY → USD)1 USD = 152.50 JPY(Ask)MidRate + SpreadSourceAmount ÷ AppliedRate
外貨売却(USD → JPY)1 USD = 151.50 JPY(Bid)MidRate - SpreadSourceAmount × AppliedRate

🚨 Alert: 計算方向を混同すると利益と損失が逆転します。CurrencyPair を列挙型で定義し、乗算・除算を隠蔽することでミスを防いでいます。

通貨ごとの端数処理(銀行利益の観点)

通貨BUY 時の丸めSELL 時の丸め
JPYCEILING(切り上げ)DOWN(切り捨て)
USD / EUR / GBPCEILINGDOWN

顧客が外貨を購入する時は切り上げ(顧客が多く支払う)、売却する時は切り捨て(顧客の受け取りが少なくなる)とすることで、銀行側に微小な利益が積み上がります。

急変時自動サスペンド(VolatilityCircuitBreaker)

特定通貨ペアのレートが1分間に3%以上変動した場合、当該ペアの新規取引を自動停止します。停止期間はデフォルト5分で、volatility_circuit_breaker_settings テーブルでパラメータを変更できます。為替ディーラーは管理画面から手動解除できます。


4. 資産運用ドメイン

アクター

アクター説明
投資家投資信託の購入・解約・ポートフォリオ照会を行う顧客
信託銀行約定処理・受渡日管理・基準価額の更新を行う外部機関
当局(税務署連携)NISA非課税枠の利用状況・特定口座の損益計算に関する税務処理

ブラインド方式(Forward Pricing)の原則

投資信託は注文時点では基準価額(NAV)が確定していません。このブラインド方式を前提に設計しています。

  • 注文は金額指定(BUY)または口数指定・全額解約(SELL)で受け付ける
  • 「何口買えるか」は翌営業日の基準価額確定後にバッチで算出し、investment_orders.units に書き戻す
  • UI では「概算口数」を表示し、確定前である旨を明示する

注文ステータスの流れ: PENDING(受付)→ ACCEPTED(資金拘束完了)→ EXECUTED(口数確定・約定)

NISA枠の利用状況管理

2024年以降の新NISAの年間・生涯上限を管理します。

  • つみたて投資枠: 120万円/年
  • 成長投資枠: 240万円/年
  • 合計生涯投資枠: 1,800万円

売却した翌年1月1日に生涯投資枠が復活する「翌年復活バッチ」も設計に含んでいます。

平均取得単価の計算(移動平均法)

追加購入のたびに取得原価総額を更新し、一部売却時は「売却口数 × 売却直前の平均取得単価」を控除します。このロジックは Holding.addUnits() / Holding.removeUnits() にドメインメソッドとして封じ込め、外部から直接変更できない構造にしています。


5. 認証・KYCドメイン

アクター

アクター説明
顧客eKYC 本人確認を受ける利用者
KYC 審査担当者書類確認・承認・拒否を行う内部担当者
システム書類有効期限チェック・ステータス自動更新を行うバッチ処理

eKYCステータス遷移

UNVERIFIED(初期状態)
  → DOCUMENTS_SUBMITTED(書類提出完了)
    → UNDER_REVIEW(審査中)
      → VERIFIED(確認完了)
      → REJECTED(確認拒否)
        → RESUBMISSION_REQUIRED(再提出要求)
          → DOCUMENTS_SUBMITTED(再提出)

ステータス別APIアクセス制御

KYC ステータス利用可能な機能
VERIFIED全機能(振込・外貨・投資)
UNDER_REVIEW残高照会・履歴参照のみ
その他基本照会のみ

認証認可は利便性ではなく、境界をどこで切るかが設計の中心です。認可フロー(PAR / JARM / MTLS)は外部IdP(Keycloak / Auth0等)に委ね、APIサーバーはトークン検証のみに専念する構成にしました。


🏗️ 基本設計編:モジュラモノリスで境界を強制する

モジュラモノリスを選んだ理由

この段階での目標は、単一プロセスで開発速度を確保しつつ、将来の分割可能性を捨てないことでした。

採用した構成は、ドメインごとのモジュール分割です。

  • common
  • auth
  • accounts
  • loans
  • forex
  • investments
  • app

これにより、まずは1プロセスで運用しながら、依存境界を明示できます。

各ドメインのAPIエンドポイント設計

口座管理API

メソッドパス説明
GET/v1/accounts/{accountId}/balanceリアルタイム残高取得
GET/v1/accounts/{accountId}/transactions明細取得(カーソルページネーション)
POST/v1/accounts/{accountId}/withdraw出金 ⚠️ X-Idempotency-Key 必須
POST/v1/accounts/{accountId}/deposit入金 ⚠️ X-Idempotency-Key 必須
POST/v1/accounts/{accountId}/transfer振込 ⚠️ X-Idempotency-Key 必須
GET/v1/accounts/{accountId}口座情報取得

POST系エンドポイントは全て X-Idempotency-Key ヘッダーを必須とします。同一キーによる再リクエストは最初のレスポンスをそのまま返します(べき等保証)。

明細取得はカーソルページネーションを採用しています。

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
}

ローンAPI

メソッドパス説明
POST/v1/loans/simulations返済シミュレーション(償還表生成)
POST/v1/loans/applicationsローン申込
GET/v1/loans/applications/{applicationId}申込ステータス確認
GET/v1/loans/contracts/{contractId}契約情報取得
GET/v1/loans/contracts/{contractId}/schedule返済スケジュール取得
POST/v1/loans/contracts/{contractId}/repayments約定返済実行(月次バッチ)
POST/v1/loans/contracts/{contractId}/prepayments繰上返済

シミュレーションAPIは償還表をレスポンスとして返します。

// リクエスト
POST /v1/loans/simulations
{
  "principal": 3000000,
  "annualInterestRate": 0.035,
  "termMonths": 60
}
// レスポンス
{
  "monthlyPayment": 54687,
  "totalPayment": 3281220,
  "totalInterest": 281220,
  "schedule": [
    { "installmentNo": 1, "paymentDate": "2026-06-27",
      "principal": 46187, "interest": 8500, "balance": 2953813 }
  ]
}

繰上返済は prepaymentType(期間短縮型 / 返済額軽減型)を指定し、実行後は返済スケジュール全体を再計算します。

外貨API

メソッドパス説明
GET/v1/forex/rates通貨ペアの現在レート一覧取得
GET/v1/forex/quotes取引見積取得(QuoteID発行)
POST/v1/forex/exchangeQuoteIDを用いた為替実行
GET/v1/forex/history為替取引履歴取得

見積取得と実行を分離する「2ステップ方式」を採用しました。

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には30秒のTTLをRedisで管理します。1回のみ使用可能で、期限切れや再利用は拒否します。

資産運用API

メソッドパス説明
GET/v1/investments/portfolio保有資産一覧と損益状況取得
GET/v1/investments/funds購入可能な投資信託一覧取得
GET/v1/investments/funds/{fundCode}投資信託詳細・基準価額履歴
POST/v1/investments/orders購入・解約注文
DELETE/v1/investments/orders/{orderId}注文取消(締切時間前かつ PENDING のみ有効)
GET/v1/investments/orders/{orderId}注文ステータス確認
GET/v1/investments/tax-exemptionsNISA枠利用状況確認

注文取消は締切時間(15:00 JST)を過ぎた注文は不可、ステータスが ACCEPTED 以降も不可とします。

認証・KYCAPI

メソッドパス説明
GET/api/v1/kyc/me自身の KYC ステータス照会
POST/api/v1/kyc/submit書類提出(ステータス遷移)
GET/api/v1/kyc/{customerId}審査担当者: 顧客 KYC 照会
PUT/api/v1/kyc/{customerId}/review審査担当者: 審査結果更新

データモデル設計:ドメインごとのER図

口座管理

┌─────────────────────────────┐
│ 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(UPDATE/DELETE禁止)
├─────────────────────────────┤
│ transaction_id   VARCHAR PK │
│ account_id       VARCHAR FK │
│ transaction_type VARCHAR    │  (DEPOSIT / WITHDRAWAL / TRANSFER_IN / TRANSFER_OUT)
│ amount           DECIMAL    │
│ balance_after    DECIMAL    │  (取引後残高スナップショット)
│ transfer_id      VARCHAR    │  (振込の場合: 対レコードを同一IDで紐付け)
│ executed_at      TIMESTAMPTZ│
└─────────────────────────────┘

┌─────────────────────────────┐
│ idempotency_keys            │
├─────────────────────────────┤
│ idempotency_key  VARCHAR PK │
│ response_body    JSONB      │
│ status_code      INTEGER    │
│ expires_at       TIMESTAMPTZ│  (created_at + 24h)
└─────────────────────────────┘

📝 Note: transactions はappend-onlyにしています。取引後残高を balance_after としてスナップショット保存することで、ある時点の残高を再計算せずに照会できます。

ローン

┌─────────────────────────────┐
│ 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     │  (残高)
│ accrued_late_charge DECIMAL │  (日次バッチが更新する未収遅延損害金)
│ contract_expires_at TIMESTAMPTZ │  (CONTRACTED→EXECUTEDの有効期限)
└─────────────────────────────┘

┌─────────────────────────────┐
│ 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    │  (繰上返済による再計算のたびに +1)
└─────────────────────────────┘

繰上返済後の償還表は schedule_version を +1 してバージョン管理します。旧バージョンは削除せず、監査目的で保持します。

外貨

┌─────────────────────────────┐
│ quote_records               │  (QuoteStatusの永続管理)
├─────────────────────────────┤
│ 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_orders           │
├─────────────────────────────┤
│ order_type      VARCHAR     │  (BUY / SELL)
│ amount          DECIMAL     │  (金額指定。BUY時)
│ units           DECIMAL     │  (約定後に確定。SELL時は注文時に指定)
│ estimated_units DECIMAL     │  (ブラインド方式の概算口数。UI表示用)
│ nav_at_order    DECIMAL     │  (約定時の基準価額)
│ status          VARCHAR     │  (PENDING/ACCEPTED/EXECUTED/CANCELLED)
│ cancellable_until TIMESTAMPTZ │  (取消可能期限: 当日15:00 JST)
└─────────────────────────────┘

┌─────────────────────────────┐
│ holdings                    │
├─────────────────────────────┤
│ units           DECIMAL     │
│ acquisition_cost DECIMAL    │  (取得原価総額: 移動平均法で更新)
└─────────────────────────────┘

┌─────────────────────────────┐
│ tax_exempt_slots            │
├─────────────────────────────┤
│ slot_type       VARCHAR     │  (NISA_ACCUMULATE/NISA_GROWTH/LIFETIME)
│ year            INTEGER     │
│ used_amount     DECIMAL     │
│ limit_amount    DECIMAL     │
└─────────────────────────────┘

共通(監査・KYC)

┌─────────────────────────────┐
│ audit_logs                  │
├─────────────────────────────┤
│ trace_id        VARCHAR     │
│ operator_id     VARCHAR     │
│ operation_type  VARCHAR     │  (INSERT/UPDATE/DELETE/API_CALL)
│ resource_type   VARCHAR     │  (テーブル名 or APIパス)
│ before_value    JSONB       │  (変更前の値)
│ after_value     JSONB       │  (変更後の値)
│ 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     │
└─────────────────────────────┘

ArchUnitで設計ルールを実行時ではなくCIで守る

口約束のルールは破られます。そこで、次を自動検証対象にしました。

  • domainはapplicationに依存しない
  • domainはapiに依存しない
  • domainはinfrastructureに依存しない
  • applicationはControllerに依存しない
  • Controllerはapi配下に置く
  • Serviceはapplication配下に置く
  • Repositoryはdomain.repository配下に置く

これにより、実装者の判断ぶれをテストで矯正できます。

共通基盤の設計意図

Transactional Outbox

非同期連携で最も危険なのは、DB更新成功とイベント発行失敗の不一致です。

そこで、ドメイン更新とoutbox書き込みを同一トランザクションに束ね、別プロセスで配信します。

  • 書き込みは原子的に成功させる
  • 配信は再試行可能にする

この構成は、将来Kafka等へ移行する際の接続点になります。

監査と追跡

監査ログの物理分離と非同期化も設計に含めています。

各 Service
     │ ApplicationEvent 発行(同期処理に影響しない)

 AuditEventPublisher(非同期)
     │ Kafka / Amazon Kinesis に投み込み

 監査ログコンシューマー
     ├──▶ audit_logs DB(PostgreSQL 別インスタンス)
     └──▶ DWH / S3(長期保存・分析用)
  • TraceIdでリクエスト単位追跡
  • 監査ログには操作者ID・操作日時・対象テーブル・変更前後の値(JSON)を記録
  • ログへのPII出力は禁止(customerId のUUIDのみ出力し、個人情報はDBから参照)

💻 詳細設計・実装編:金融品質をコードに落とす

Value Object: Money

金融計算の基礎となる Money をimmutableなレコードとして定義しました。

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("金額は0以上である必要があります");
        }
    }

    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("減算結果が負になります");
        }
        return new Money(this.amount.subtract(other.amount), this.currency);
    }

    /**
     * JPY は HALF_DOWN(切り捨て)、その他は 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運用と悲観的ロック

残高更新は悲観的ロック(SELECT FOR UPDATE)を採用しました。

楽観的ロックでも実装可能ですが、残高という金融上クリティカルな値に対して再試行を許容すると、コード複雑度と予期しないリトライによる副作用リスクが上がります。悲観的ロックにより1リクエスト1コミットを保証しています。

出金フローのシーケンスは以下の通りです。

Client

  ▼  AccountWithdrawService.withdraw(accountId, amount, idempotencyKey)

  ├─ [1] idempotencyKeyRepository.find(idempotencyKey)
  │        └─ 存在する場合: 保存済みレスポンスをそのまま返す

  ├─ [2] @Transactional 開始

  ├─ [3] accountRepository.findByIdForUpdate(accountId)
  │        └─ SELECT ... FOR UPDATE(行ロック取得)

  ├─ [4] dailyCacheRepository.findForUpdate(accountId, today)
  │        └─ 当日累計出金額を取得してロック

  ├─ [5] account.withdraw(amount, todayAccumulated, limitPolicy)
  │        └─ ステータス → 限度額 → 残高 の順で検証

  ├─ [6] transactionRepository.save(newTransaction)
  │        └─ INSERT (balance_after = 更新後残高)

  ├─ [7] dailyCacheRepository.update(+amount)

  ├─ [8] accountRepository.save(account)

  ├─ [9] idempotencyKeyRepository.save(idempotencyKey, response)

  └─ [10] @Transactional コミット

ローン計算の実装:Value Object: InterestRate

public record InterestRate(BigDecimal annualRate) {
    /** 月利を返す。中間計算はスケール30桁確保。 */
    public BigDecimal monthlyRate() {
        return annualRate.divide(BigDecimal.valueOf(12), 30, RoundingMode.HALF_UP);
    }

    /** 日割り利率(遅延損害金・初回端数利息計算に使用)*/
    public BigDecimal dailyRate(int daysInYear) {
        return annualRate.divide(BigDecimal.valueOf(daysInYear), 30, RoundingMode.HALF_UP);
    }
}

元利均等返済の計算では、初回端数利息(融資実行日〜初回返済日の日割り利息)を別途計算します。

// M = P * r * (1+r)^n / ((1+r)^n - 1) → 30桁スケールで中間計算、最後に円単位に丸め
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);

外貨計算ロジック

通貨ペアの向きによって乗算・除算を切り替え、CurrencyPair 列挙型に内包しました。

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 レート
            case SELL -> midRate.subtract(spread); // Bid レート
        };

        // 通貨ペアの向きによって乗算・除算を切り替え(中間計算30桁)
        BigDecimal rawAmount = pair.convert(sourceAmount, appliedRate, direction, INTERMEDIATE_SCALE);

        // 通貨・方向ごとの端数処理(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): 除算  SELL(USD→JPY): 乗算 */
    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);
        };
    }
}

冪等性キー

入出金・振込・注文系には冪等性キーを導入し、リトライを安全化しました。

  • 同一キーの再送は同一結果を返す
  • 二重計上を回避する

これは可用性向上と整合性担保を同時に満たす重要な実装です。

RedisによるRate Lock(QuoteStore)

外貨ドメインでは見積と約定を分離し、QuoteにTTLを設定しました。

@Component
public class RedisQuoteStore {
    private static final Duration QUOTE_TTL = Duration.ofSeconds(30);

    public void save(ForexQuote quote) {
        // Redisに保存(TTL=30秒)
        redisTemplate.opsForValue().set("quote:" + quote.quoteId(), quote, QUOTE_TTL);
        // DBに PENDING ステータスで永続化(カスタマーサポートの調査に利用)
        quoteRecordRepository.insert(quote.toRecord(QuoteStatus.PENDING));
    }

    /** QuoteIDに紐づく Quote を取得し、Redisから削除(1回限りの使用保証)*/
    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;
    }
}

為替実行はTransactional Outboxパターンで原子性を確保しています。

@Transactional
void executeExchange(String quoteId, String accountId) {
    ForexQuote quote = quoteStore.getAndMarkUsed(quoteId);    // 1. Quote取得・USED化
    accountService.debit(accountId, quote.sourceAmount());    // 2. 円口座からの引き落とし
    outboxRepository.insert(new OutboxMessage(                // 3. 同一トランザクション内に
        "ForeignCurrencyCredit",                             //    外貨入金イベントを記録
        accountId, quote.targetCurrency(), quote.targetAmount()
    ));
    forexTransactionRepository.save(quote.toTransaction());   // 4. 取引履歴保存
    // 外貨口座への入金は Outbox Processor が非同期で実行(リトライ最大3回)
}

⚠️ Warning: 「円口座引き落とし成功 → 外貨入金失敗」というハーフコミット状態を根絶します。

Java 21 / Spring Boot 3を選んだ判断

採用理由は新しさではなく、設計との相性です。

  • Java 21
    • recordでDTOやVOを簡潔かつ不変に表現(MoneyInterestRate はrecordで実装)
    • switch式で状態遷移判定を明示化(ForexCalculator の BUY/SELL 分岐など)
  • Spring Boot 3
    • モジュール分割したAPI基盤との整合
    • セキュリティ・バッチ・データアクセスの統合運用
  • Virtual Threads
    • I/O待ち主体のAPI処理におけるスループット改善余地

特にrecordは、設計意図をコードへ直接写像しやすいという点で有効でした。


🚀 PoCとしての動作確認システム

なぜバニラHTMLとnginxだったのか

PoCの目的はUIを作り込むことではなく、API契約の確認を高速に回すことです。

そこで、次の構成を採用しました。

  • Docker Compose
  • nginx
  • 外部向け画面と内部向け画面の分離
  • バニラHTMLと最小JavaScript

実際のシステム全体構成は次の通りです。

  ┌──────────────────────────────────────────────────────┐
  │              開発者ローカルマシン(単一ホスト)       │
  │                                                      │
  │  ┌──────────────────────────────────────────────┐   │
  │  │  Docker Compose ネットワーク(banklink-net)  │   │
  │  │                                              │   │
  │  │  ┌────────────────┐  ┌────────────────────┐  │   │
  │  │  │  外部UI        │  │  内部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(手動実行コンテナ)       │  │   │
  │  │  │  - 約定処理バッチ                       │  │   │
  │  │  │  - NISA枠更新バッチ                     │  │   │
  │  │  │  - 延滞損害金バッチ                     │  │   │
  │  │  └────────────────────────────────────────┘  │   │
  │  └──────────────────────────────────────────────┘   │
  │                                                      │
  │  ブラウザ: http://localhost:8080 (外部UI)          │
  │  ブラウザ: http://localhost:8081 (内部UI)          │
  └──────────────────────────────────────────────────────┘

外部UIは一般ユーザー想定のAPI操作画面、内部UIはKYC審査など行員向け操作画面です。両者は同じAPIサーバーに接続しますが、nginxのプロキシ設定でアクセス経路を分けており、権限境界の実機確認が目的のひとつでした。

この構成の利点は、画面変更が即時反映されること、そしてAPI試験の往復が短いことです。

外部クライアント画面(ユーザー向け):

外部クライアント画面

Phase 1で確認できたこと

  • 5ドメイン主要APIの疎通
  • 権限境界の基本動作
  • 監査・Traceの基礎確認
  • Dev向け補助APIによるテスト循環

動作確認画面(外部UI)

口座残高照会・取引履歴の取得:

口座API動作確認

返済シミュレーション(元利均等60回):

ローン返済シミュレーション

外貨両替 見積取得(JPY→USD 10,000円):

外貨見積取得

ポートフォリオ照会・NISA枠確認:

投資・NISA枠確認

動作確認画面(内部UI / 行員向け)

KYC審査待ち一覧と審査フォーム:

KYC審査画面(内部UI)

🔗 関連記事: この vanilla HTML 版 PoC を後に Vue 3 / React / Spring Boot Thymeleaf でも実装し直し、4スタックで同じ業務 UI を作って比較した記事を書きました。プロジェクト構成・API クライアント・状態管理・フォーム処理・デプロイ構成の違いをコード付きで横並びに比較しています。 → 同じ業務 Web UI を Vanilla HTML / Vue / React / Thymeleaf で実装して比較した — 4スタックの違いと選定指針

検証で見えた課題(前向きなスコープ調整として)

事実確認で見えた差分は、失敗ではなくPhase 1での意図的な切り分けとして整理できます。

  1. ローンの約定返済APIは設計側に存在するが、実装は次フェーズ扱い
  2. KYC系のパス表現に実装側とプロキシ経由表記の揺れがある
  3. 外部PoC画面は主要操作中心で、API全量を網羅するUI接続には至っていない
  4. バッチ系はPhase 1では一部をログ中心実装とし、業務ロジック本体は段階投入

📝 Note: このように、PoCで先に検証する範囲と、業務耐用で詰める範囲を意図的に分離して進めています。


🗺️ 今後の展望:業務耐用へのロードマップ

Phase 2 セキュリティ硬化

  • API Gateway / WAF導入
  • 認証基盤連携の実運用化(Keycloak / Auth0)
  • OpenAPI整備とE2E強化

狙いは、境界防御と運用可能性の引き上げです。

Phase 3 非同期連携の本格化

  • Outboxリレーの本運用
  • ドメイン間イベント連携の拡大(口座↔ローン↔投資の資金連携)
  • 結果整合性と再試行戦略の明文化

狙いは、モジュール間連携を安全にスケールさせることです。

Phase 4 バッチ処理の業務化

  • ローン約定返済バッチの本実装
  • 遅延損害金計算の本実装
  • NISA年次更新処理の本実装
  • 匿名化バッチ(解約後10年経過顧客のPII消去)

狙いは、日次・月次運用の再現性を自動化することです。

Phase 5 本番構成への移行

  • マネージドDB/キャッシュへの移行
  • 可観測性の強化
  • 高可用・障害対策の実装

狙いは、開発都合の構成から業務継続可能な構成への昇華です。


まとめ

本取り組みで重視したのは、実装速度より設計の検証可能性です。

フェーズやったこと
要件定義制約を先に決める(アクター・状態遷移・計算規約を明文化)
基本設計境界をテストで強制する(ArchUnit・ER図・APIエンドポイント設計)
詳細設計金融品質を実装へ落とす(BigDecimal・悲観的ロック・Outbox・べき等性)
PoC現実の差分を早期に可視化する
次フェーズ差分を負債として隠さず、ロードマップへ接続する

銀行系のような高信頼領域では、完成を一気に目指すより、根拠を持って段階的に硬くしていく方が結果的に速いと考えています。

設計先行は遠回りではなく、変更可能性を最後まで残すための最短経路でした。

気軽にメッセージください

技術相談・ご感想・ご質問があればメッセージをお願いします。