レガシー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参照更新]
     ↑                                                           │
     └───────────────── 次の差分へ ──────────────────────────────┘
  1. 差分調査: レガシーの実挙動(ステータス・レスポンス形・副作用)を言語化し、移植版との差分を出す。
  2. 是正計画を承認: 最小PR単位に割り、前提工事(テストDB隔離、trait追加など)と本体を分離。関連チケットのclose条件も先に決める。
  3. 小さく実装: 1PR=1是正を原則に、実害の大きい順(必ず500 → 金額ズレ → 表示崩れ)に着手。
  4. 実装結果を記録: 「何を・どのPRで・どのチケットを閉じたか」を進捗mdに残す。
  5. 参照を更新して反映: submodule構成なら親リポの参照更新で初めて本番に効く。この一歩を手順に含める。

肝は、計画承認と結果記録を必ずドキュメント化すること。発明実装は個人の勘違いから生まれるので、勘違いを記憶に置かずレビュー可能なテキストに引き出すだけで再発が激減します。

確認方法

  • 異常系テスト(存在しないID/欠損データ/空配列)を先に書き、期待値はレガシー挙動に合わせる
  • レガシーと移植版のJSONをスナップショット比較し、フィールドだけでなく包みの構造も比べる
  • モデル移植のテストは本番系接続に相乗りさせず、接続を隔離(前提工事として独立PR化)
  • 税計算はテーブル駆動テストで二重加算を回帰検知する

注意点

  • 移植フェーズでは「改善」を我慢する。良かれと思った最適化が発明実装になる。
  • findOrFail / forceFill / 生配列返し は発明実装の三大シグナル。見たら「レガシーはどうだった?」を必ず問う。
  • 食い違ったドキュメントは負債。コード修正とドキュメント修正を同じPRに含める。

まとめ

レガシー移植の失敗は、たいてい「落ちるバグ」ではなく「動くけど違う」実装から来ます。対策は派手なツールではなく、差分調査→計画承認→最小PR→結果記録→反映という地味なループを型として回すこと。そして忠実性を測る物差しは常に「レガシーの実挙動」に置くこと。改善はその後で十分です。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。