レガシーAPI移植で一番怖いのは「動くけど違う」実装 — 忠実性を守る是正ループ
はじめに
旧システムのAPIを新しいフレームワーク(本記事ではLaravelを例にします)へ移植する案件で、1週間のプルリクエストのほとんどが「新実装がレガシーと微妙に違うのを直す」作業になりました。
移植で本当に怖いのは、落ちて気づけるバグではありません。レビューも素通りし、ステージングでも一見動いてしまう「発明実装」です。実装者が旧挙動を確認しきれず「たぶんこうだろう」で書いたコードが、本番データや連携先の期待と静かに食い違う。本記事では、その典型パターンと安全に潰すための是正ループを、フレームワーク非依存の教訓として一般化します。
前提とする環境
- 旧システム: 古い言語/フレームワークで書かれたWeb API
- 新システム: Laravel + Eloquent、API ResourceでJSON整形
- 構成: 複数サービスを submodule で親リポに束ね、各PRをマージ後に親の参照を更新して反映
- 方針: 「改善」ではなく「忠実な再現」が第一目標
発明実装の典型6パターン
1. 存在しない前提で落ちる(404ガード + 欠損値の吸収)
移植版は正常系データしか想定せず、存在しないIDや未登録の付随情報で例外になっていました。レガシーはそこを空扱いで通していました。
// 発明実装: 無いIDでそのまま触って例外 → 500
public function show($id)
{
$vehicle = Vehicle::findOrFail($id);
return new VehicleResource($vehicle);
}
// 忠実化: レガシー準拠の404と、欠損名称の吸収
public function show($id)
{
$vehicle = Vehicle::find($id);
if ($vehicle === null) {
return response()->json(['data' => null], 404);
}
$vehicle->maker_name = $vehicle->maker_name ?? '';
return new VehicleResource($vehicle);
}
findOrFail は「落ちることが仕様」の場所にだけ使い、異常系のステータスとレスポンス形までレガシーに合わせます。
2. 計算の二重適用(外税の二重加算)
税込み合計を、サービス層で足した上に Resource でも足していました。片方なら正しいが両方通ると二重になります。
// 発明実装: サービス層で加算済みなのにResourceでも加算 → 二重 'total' => $this->subtotal + $this->tax, // 忠実化: 計算は一点に集約し、Resourceは持ち回るだけ 'total' => $this->total,
金額・数量など順序依存の値は、計算箇所を複数に散らさないことが肝心です。
3. mass assignment の迂回で更新が欠落する
$fillable に列が無いため保存されず、それを forceFill で回避していました。回避の結果、関連する明細テーブルの更新が漏れることがありました。
// 発明実装: forceFillで迂回 → 関連明細の更新が抜ける
$item->forceFill($request->all())->save();
// 忠実化: fillableを明示し、親子を同一トランザクションで更新
protected $fillable = ['status', 'qty', 'price'];
DB::transaction(function () use ($request, $item) {
$item->fill($request->only($item->getFillable()))->save();
$item->details()->update(['updated_at' => now()]);
});
forceFill は「なぜ fillable を通せないのか」を問う合図。裏に関連更新の抜けが潜みがちです。
4. 論理削除の欠落(SoftDeletes / withTrashed)
レガシーは論理削除なのに、移植モデルに trait が無く物理挙動になっていました。
class Dealer extends Model
{
use SoftDeletes; // 欠落していた
}
// 既存の論理削除済みを拾う箇所は明示
Dealer::withTrashed()->find($id);
削除の意味論(物理/論理)は移植で真っ先に突き合わせます。欠落は「消えないはずが消える」「消したはずが見える」の両方を生みます。
5. レスポンスの「包み」のズレ
一覧APIで、レガシーは { "data": [...] } の形で返していたのに、移植版が生配列や二重包みで返していました。
// 発明実装: 包みの形がレガシーと違う return ColorResource::collection($colors); // 忠実化: レガシーのレスポンス契約に合わせる return ['data' => ColorResource::collection($colors)->resolve()];
API契約はフィールド名だけでなく、入れ子構造(包み)まで含みます。
6. ドキュメントの虚偽記述
実装と食い違う記述が規約ファイルに残っていました。これは次の実装者を発明実装へ誘導する最大の罠なので、コードと同じ優先度で是正し、コード修正とドキュメント修正を同じPRに含めます。
是正ループ(本題)
個々のバグ修正より価値があったのは、同じ型で回したプロセスです。
[差分調査] → [是正計画の承認] → [最小PR実装] → [結果記録] → [submodule参照更新]
↑ │
└───────────────── 次の差分へ ──────────────────────────────┘
- 差分調査: レガシーの実挙動(ステータス・レスポンス形・副作用)を言語化し、移植版との差分を出す。
- 是正計画を承認: 最小PR単位に割り、前提工事(テストDB隔離、trait追加など)と本体を分離。関連チケットのclose条件も先に決める。
- 小さく実装: 1PR=1是正を原則に、実害の大きい順(必ず500 → 金額ズレ → 表示崩れ)に着手。
- 実装結果を記録: 「何を・どのPRで・どのチケットを閉じたか」を進捗mdに残す。
- 参照を更新して反映: submodule構成なら親リポの参照更新で初めて本番に効く。この一歩を手順に含める。
肝は、計画承認と結果記録を必ずドキュメント化すること。発明実装は個人の勘違いから生まれるので、勘違いを記憶に置かずレビュー可能なテキストに引き出すだけで再発が激減します。
確認方法
- 異常系テスト(存在しないID/欠損データ/空配列)を先に書き、期待値はレガシー挙動に合わせる
- レガシーと移植版のJSONをスナップショット比較し、フィールドだけでなく包みの構造も比べる
- モデル移植のテストは本番系接続に相乗りさせず、接続を隔離(前提工事として独立PR化)
- 税計算はテーブル駆動テストで二重加算を回帰検知する
注意点
- 移植フェーズでは「改善」を我慢する。良かれと思った最適化が発明実装になる。
findOrFail/forceFill/ 生配列返し は発明実装の三大シグナル。見たら「レガシーはどうだった?」を必ず問う。- 食い違ったドキュメントは負債。コード修正とドキュメント修正を同じPRに含める。
まとめ
レガシー移植の失敗は、たいてい「落ちるバグ」ではなく「動くけど違う」実装から来ます。対策は派手なツールではなく、差分調査→計画承認→最小PR→結果記録→反映という地味なループを型として回すこと。そして忠実性を測る物差しは常に「レガシーの実挙動」に置くこと。改善はその後で十分です。
