段階的導入
レガシー Scalaコードベースを一括置換すると、境界・エラー・永続化の穴が同時に広がる。Kamaeでは触れたワークフローごとに、DTOパース → 型付き状態 → ポート分離 → 原子性永続化の順で段階的に締める。
各段の詳細は 境界防御、アプリケーション配線、ORM アダプタ を参照する。
Kamaeはまず新しいコードパスに適用する。既存コードは、機能追加やバグ修正で触る箇所から段階的に引き締める。ドメイン全体の書き直しでリリースを止めない。
レガシーな慣習と衝突する場合、触っていないコードはローカルな慣習に従い、新旧の境界では新しい境界を明示的に文書化する。
レガシーな形を認識する
Section titled “レガシーな形を認識する”Scalaサーバーコードベースでよくある出発点:
- 貧血case classとservice objectやimplicit extensionの乱立
- Slick / doobieの行型をドメインエンティティとして使う
- opaque typeの代わりに
StringのIDやstatus文字列 - ビジネスロジック中の
throw、.get、素のException - controllerやrouteがJDBC / HTTPを直接呼ぶ
これらは移行の出発点であり、失敗ではない。次に起きそうなバグを取り除く最小の変更を選ぶ。
一度に一段ずつ進める。各ステップは単独でレビューできること。
| Step | 変更 | 典型的な触りどころ | リスク |
|---|---|---|---|
| 0. 境界のみ | DTO/row → 新エンドポイント向け Either パース | http4s / Pekko route、consumer | 低 |
| 1. ID と値オブジェクト | RequestId、Money、OccurredAt などの opaque type | 変更フローで使う models | 低 |
| 2. ドメインエラー | 新ユースケースでの error ADT | application layer | 低 |
| 3. 型付き状態 | 重要な集約 1 つ分の sealed state | domain module | 中 |
| 4. ポート | 新ユースケースの背後に小さな repository trait | application + infrastructure | 中 |
| 5. トランザクションとバージョン | 原子的 save、outbox、楽観的バージョンチェック | persistence adapter | 中〜高 |
コードベースがすでに満たしているステップだけスキップする。
サービス全体ではなく機能で絞り込む(Strangler Fig)
Section titled “サービス全体ではなく機能で絞り込む(Strangler Fig)”レガシーモジュールに対して:
- 変更したワークフロー用に新しいuse-caseクラスを追加する。
- 新パスが実証されるまで、旧エントリポイントはレガシーコードを呼び続ける。
- 新APIバージョン、フラグ、コマンドを新ユースケースへルーティングする。
- パリティテストが通ったら旧パスを削除する。
legacy route -> legacy service -> JDBCnew route -> AssignDriver use case -> port -> adapter -> JDBC移行スライスは集約1つ、またはエンドポイント1つを優先する。
レガシー移行の段階的ロードマップ
Section titled “レガシー移行の段階的ロードマップ”例: http4s + doobieのモノリスサービスで POST /requests/{id}/assign を移行する。
Phase 1 — 挙動を固定し、テストを追加(1 週目)
Section titled “Phase 1 — 挙動を固定し、テストを追加(1 週目)”- 統合テストで現行HTTP契約を記録する(ステータスコード、JSON形状)。
- レガシーパス周辺にlogging / metricsを追加し、トラフィックを計測する。
- まだ挙動は変えない。
Phase 2 — 境界 DTO(1〜2 週目)
Section titled “Phase 2 — 境界 DTO(1〜2 週目)”interfacesモジュールにAssignDriverBodyとAssignDriverDtoを導入する。- routeの直接フィールドアクセスを
AssignDriverCommandのEitherパースに置き換える。 - レガシーサービスはまだ文字列を受け取る。検証は境界に移る。
- 同じrouteのまま出荷する。テストは緑のまま。
境界防御 を参照。
Phase 3 — 触った ID の opaque type(2 週目)
Section titled “Phase 3 — 触った ID の opaque type(2 週目)”domainモジュールにRequestId、DriverIdopaque typeを追加する。- 境界パースをopaque type構築に変更する。レガシーサービスは境界で
.valueを受け取る。 - 新しい
domainモジュールだけに厳格なscalafix / scalacオプションを有効化する。
ドメインモデリング を参照。
Phase 4 — ユースケース抽出(3 週目)
Section titled “Phase 4 — ユースケース抽出(3 週目)”- レガシー SQLをprivateメソッドにインラインした
AssignDriverを作成する。 - ハンドラは
useCase.execute(cmd)のみ呼ぶ。 - このパスの例外を
AssignDriverErrorADTとEitherに置き換える。
エラーハンドリング を参照。
Phase 5 — 1 集約の型付き状態(3〜4 週目)
Section titled “Phase 5 — 1 集約の型付き状態(3〜4 週目)”WaitingRequestとEnRouteRequestをモデル化し、割当をWaitingRequest.assignDriverに移す。- DBのレガシー
status: Stringは残す。adapterがrow ↔ state typeをマップする。 - HTTPなしで遷移の単体テストを追加する。
Phase 6 — リポジトリポート(4〜5 週目)
Section titled “Phase 6 — リポジトリポート(4〜5 週目)”TaxiRequestRepository[F]と関連port traitを定義する。- SQLをユースケースから
DoobieTaxiRequestRepositoryに移す。 - ユースケースはtraitのみに依存する。
mainまたはコンポジションルートで配線する。
永続化、集約、イベント と アプリケーション配線 を参照。
Phase 7 — トランザクション、バージョン、outbox(5〜6 週目)
Section titled “Phase 7 — トランザクション、バージョン、outbox(5〜6 週目)”version列と条件付きUPDATEを追加する。- state saveとoutbox insertを1トランザクションに包む。
- リトライクライアント向けにidempotency keyを追加する。
永続化、集約、イベント を参照。
Phase 8 — レガシーパス削除(6 週目以降)
Section titled “Phase 8 — レガシーパス削除(6 週目以降)”- feature flagまたはrouteトラフィックが新パス100% であることを確認する。
- レガシーサービス関数と死んだ
status文字列チェックを削除する。 - 移行モジュールに
kamae-scala-reviewを実行する。
ペースはチーム規模に合わせて調整する。可能なら各フェーズを独立PRにする。
差分をレビュー可能に保つ
Section titled “差分をレビュー可能に保つ”- 避けられる限り、機械的リファクタと挙動変更を1 PRに混ぜない。
- 旧パスを削除する前に、新境界にテストを追加する。
- 触ったフィールドだけopaque typeとDTO変換を導入し、後で広げる。
- 強化するモジュールで追加scalafixルールを有効化する。
- 新旧の意味論が異なる場合のみ、短いコメントまたはADRを残す。
ラダーを登り止めるタイミング
Section titled “ラダーを登り止めるタイミング”すべてのstructにstate machineやrepository traitは不要。次の場合は現段階で止める:
- コードが安定し、低リスクで、めったに変わらない
- 集約に意味のあるライフサイクルや不変条件がない
- チームがpersistenceや並行性の挙動をまだ十分にテストできない
バグ、コンプライアンス要件、並行性が現状の形では弱すぎると示したら、一段上げる。
エージェントとレビュアーの期待
Section titled “エージェントとレビュアーの期待”移行時:
- スコープ判断に 段階的導入 を読み込む
- 実装する段のトピックガイドを読み込む
- 周囲がレガシーでも、変更パスに
kamae-scala-reviewを使う - サービス全体が移行済みのふりをせず、残るレガシーリスクを明示する