設計から入る銀行系API構築:5ドメインの要件定義からPoC検証、そして業務耐用へのロードマップ
この記事でわかること
- 口座・ローン・外貨・資産運用・KYCの5ドメインで「先に固定すべき制約」を設計でどう扱うか
- モジュラモノリスによるドメイン境界の強制方法(ArchUnit活用を含む)
- BigDecimal・悲観的ロック・Transactional Outbox・冪等性キーを組み合わせた金融品質の実装
- PoCとして動かして見えた課題と、次フェーズへの接続方法
対象読者
- 金融・Fintech領域のAPI設計・実装に興味がある方
- DDD / ドメインモデル設計を実践してみたい方
- Spring Boot + Java でのバックエンド実装の設計根拠を深めたい方
動作環境
| 項目 | バージョン |
|---|---|
| Java | 21(Virtual Threads) |
| Spring Boot | 3.x |
| PostgreSQL | 16 |
| Redis | 7 |
| ビルドツール | Maven(マルチモジュール) |
| コンテナ | Docker Compose |
はじめに
銀行系システムを実装するとき、最初に直面するのは機能の多さではなく、失敗できない領域をどう設計で扱うかです。
- 金額計算の誤差を許さない
- 二重実行を許さない
- 監査可能性を落とさない
- 将来の分割や拡張に耐える
本記事では、口座・ローン・外貨・資産運用・KYCの5ドメインを対象に、設計の3フェーズを軸として銀行系APIを構築したプロセスを整理します。
背骨は次の順序です。
- 要件定義
- 基本設計
- 詳細設計
そして、その設計を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_AML | AMLシステムによる自動検知 | コンプライアンス担当者のみ |
📝 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 + Spread | SourceAmount ÷ AppliedRate |
| 外貨売却(USD → JPY) | 1 USD = 151.50 JPY(Bid) | MidRate - Spread | SourceAmount × AppliedRate |
🚨 Alert: 計算方向を混同すると利益と損失が逆転します。
CurrencyPairを列挙型で定義し、乗算・除算を隠蔽することでミスを防いでいます。
通貨ごとの端数処理(銀行利益の観点)
| 通貨 | BUY 時の丸め | SELL 時の丸め |
|---|---|---|
| JPY | CEILING(切り上げ) | DOWN(切り捨て) |
| USD / EUR / GBP | CEILING | DOWN |
顧客が外貨を購入する時は切り上げ(顧客が多く支払う)、売却する時は切り捨て(顧客の受け取りが少なくなる)とすることで、銀行側に微小な利益が積み上がります。
急変時自動サスペンド(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/exchange | QuoteIDを用いた為替実行 |
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-exemptions | NISA枠利用状況確認 |
注文取消は締切時間(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を簡潔かつ不変に表現(
Money、InterestRateはrecordで実装) - switch式で状態遷移判定を明示化(
ForexCalculatorの BUY/SELL 分岐など)
- recordでDTOやVOを簡潔かつ不変に表現(
- 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)
口座残高照会・取引履歴の取得:

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

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

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

動作確認画面(内部UI / 行員向け)
KYC審査待ち一覧と審査フォーム:

🔗 関連記事: この vanilla HTML 版 PoC を後に Vue 3 / React / Spring Boot Thymeleaf でも実装し直し、4スタックで同じ業務 UI を作って比較した記事を書きました。プロジェクト構成・API クライアント・状態管理・フォーム処理・デプロイ構成の違いをコード付きで横並びに比較しています。 → 同じ業務 Web UI を Vanilla HTML / Vue / React / Thymeleaf で実装して比較した — 4スタックの違いと選定指針
検証で見えた課題(前向きなスコープ調整として)
事実確認で見えた差分は、失敗ではなくPhase 1での意図的な切り分けとして整理できます。
- ローンの約定返済APIは設計側に存在するが、実装は次フェーズ扱い
- KYC系のパス表現に実装側とプロキシ経由表記の揺れがある
- 外部PoC画面は主要操作中心で、API全量を網羅するUI接続には至っていない
- バッチ系は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 | 現実の差分を早期に可視化する |
| 次フェーズ | 差分を負債として隠さず、ロードマップへ接続する |
銀行系のような高信頼領域では、完成を一気に目指すより、根拠を持って段階的に硬くしていく方が結果的に速いと考えています。
設計先行は遠回りではなく、変更可能性を最後まで残すための最短経路でした。