ある日、上流から流れてくるデータの識別子フォーマットが変わりました。それまで「5桁の数字」だったものに、「4桁の英数字 + 末尾1文字」という新形式が混ざり始めたのです。
既存の正規化ロジックは旧形式しか想定していませんでした。結果、新形式の 446A0 を 446A に誤って短縮し、6,848件のレコードが一括でエラーフラグ付きになりました。
本記事は、この後始末から学んだ「新旧フォーマットの混在を、巨大な分岐ではなく不変条件(invariant)のガード式1行で吸収する」という後方互換の型の記録です。AI agent に修正を依頼するときにも、この型を指示できると修正が一気に小さくなります。
事の発端:正規化関数が新形式を「短縮」していた
問題のコードはこんな正規化関数でした(匿名化しています)。
1def normalize_record_id(raw: str) -> str:2 # 末尾の桁を落として親IDに正規化する、という旧来の仕様3 if raw.endswith("0"):4 return raw[:-1]5 return raw旧形式の識別子は「5桁すべて数字、末尾が 0 なら親IDを表す」という規約でした。12340 → 1234 のように末尾を落とすのが正しい正規化です。
ところが新形式 446A0 が来たとき、このコードは末尾の 0 を見て 446A に短縮してしまいました。新形式の 446A0 は「これ全体で1つの識別子」であって、末尾の 0 は親子関係を表すものではなかったのです。
短縮された 446A は当然どのマスタにも存在しません。下流の照合で全部「該当なし」になり、6,848件にエラーフラグが立ったわけです。
最初に却下した修正:新旧で関数を分ける
AI agent に修正を依頼すると、最初はこういう案を出してきました。
1def normalize_record_id(raw: str) -> str:2 if is_new_format(raw):3 return normalize_new(raw)4 else:5 return normalize_old(raw)6
7def is_new_format(raw: str) -> bool:8 return any(c.isalpha() for c in raw)一見きれいですが、これは修正範囲が広がりすぎる案です。
normalize_new/normalize_oldの2関数を新設し、それぞれにテストを書く必要があるis_new_formatの判定ロジックが間違うと、両方のパスが汚染される- 既存の呼び出し側がすべて旧
normalize_record_idの挙動を前提にしているので、リグレッションの範囲が読めない
つまり「全置換に近い改修」になります。混在期間という一時的な状態のために、恒久的なコードを大きく書き換えるのは割に合いません。
採用した修正:旧形式だけが満たす不変条件をガードで囲う
実際に入れた修正は、たった1つのガード式でした。
1def normalize_record_id(raw: str) -> str:2 # 旧形式の親ID正規化が成立する不変条件:3 # 「5桁ちょうど」かつ「全部数字」かつ「末尾が0」4 # 新形式 446A0 は isdigit() が False なのでこの枝に入らない5 if len(raw) == 5 and raw.isdigit() and raw.endswith("0"):6 return raw[:-1]7 return raw差分にすると +11行(ガード式とコメント)だけです。ポイントは、**「新形式を判定して分岐する」のではなく「旧形式だけが満たす不変条件を厳密に書く」**という発想の転換です。
446A0 は isdigit() が False を返すので、このガードには絶対に入りません。新形式を一切「知らない」まま、旧形式だけを安全に処理して、それ以外は素通しするのです。
なぜこれで両立するのか
正規化の意図を分解すると、こうです。
| 入力 | 旧仕様の意図 | ガード式の評価 | 結果 |
|---|---|---|---|
12340 | 親IDに正規化 | 5桁・数字・末尾0 → True | 1234(正しい) |
12345 | そのまま | 末尾が0でない → False | 12345(正しい) |
446A0 | そのまま(新形式) | isdigit() が False → False | 446A0(正しい) |
4 | そのまま | 5桁でない → False | 4(正しい) |
旧形式の「親ID」だけがガードを通過し、新形式は 判定不要で素通しされます。新形式のためのコードは1行も書いていません。
学び:後方互換は「分岐」ではなく「不変条件の明示化」
この修正から得た普遍的な学びは、こうです。
フォーマット変更を吸収するとき、「新形式をどう扱うか」を考える前に、**「旧仕様が成立する前提条件(不変条件)は何か」**を厳密に言語化する。それをガード式にすれば、新形式は自動的にすり抜ける。
旧来のコードは多くの場合、**「成立する前提を暗黙のうちに仮定」**しています。今回なら「入力は必ず5桁数字である」という仮定です。フォーマットが変わるとこの仮定が崩れ、暗黙だったがゆえに気づけずバグります。
修正の本質は「新形式に対応する」ことではなく、暗黙だった前提を明示的なガードに昇格させることです。前提を書き切れば、それを満たさない入力(=新形式)は自然に対象外になります。
AI agent にこの型を指示する
AI agent はデフォルトでは「分岐して両対応」案を出しがちです。理由は、
- 「新機能(新形式対応)を追加する」というフレームで問題を捉える
- 「既存の挙動には触らず、新しい枝を足す」のが安全だと学習している
しかしフォーマット混在のような過渡的な問題では、分岐の追加はむしろ表面積を増やします。だから依頼時にこう枠を与えます。
1## 後方互換修正の方針2
3新形式に「対応」する分岐を足すのではなく、4旧仕様が成立する不変条件(invariant)をガード式で明示すること。5
6- 旧ロジックが正しく動く前提条件を列挙する7 (長さ・型・文字種・接頭/接尾の規約など)8- その前提を満たす入力だけがガードを通過するようにする9- 前提を満たさない入力(=新形式)は素通しにする10- 修正は最小差分にとどめ、既存テストを壊さないことこの指示を与えると、agent は「新形式の 446A0 をどう扱うか」ではなく「旧形式の 12340 だけが満たす条件は何か」を考えるようになります。修正範囲が劇的に小さくなります。
この型が効く場面
「不変条件のガード式」パターンは、フォーマット混在以外にも広く効きます。
- API バージョン移行: v1 のレスポンスだけが持つ構造(特定キーの存在など)をガードにし、v2 は素通しでハンドラに渡す
- プロトコル移行: 旧プロトコルのマジックナンバー / ヘッダ長を厳密にチェックし、合致しないものは新パスへ
- ID 体系変更: 旧 ID の桁数・チェックディジット規約をガードにする(まさに今回のケース)
- 設定ファイルのスキーマ移行: 旧スキーマ必須キーの有無で分岐し、移行期間中は両形式を受ける
共通するのは、**「移行は一瞬では終わらず、新旧が混在する期間が必ずある」**という現実です。その期間を恒久コードの大改修で乗り切ろうとすると割に合いません。旧形式の不変条件を1箇所のガードに閉じ込め、移行完了後にそのガードごと削除する——これが最小コストの後方互換戦略です。
まとめ:混在期間は「ガードで仕切る」
データフォーマットの変更は、AI 駆動開発でも頻繁に遭遇します。上流の都合で識別子の体系が変わる、外部 API がレスポンス構造を変える——こういう変化は避けられません。
そのとき、6,848件エラーのような事故を起こさず、かつ最小の修正で乗り切る型は、
- 旧ロジックが正しく動く不変条件を厳密に言語化する
- それをガード式1行に落とす(新形式の知識は一切入れない)
- 移行が完全に終わったら、そのガードごと削除する
新形式を「どう処理するか」から考えると分岐が増えます。旧形式が「いつ成立するか」から考えると、新形式は自動的にすり抜けます。後方互換の設計は、新しい何かを足すのではなく、古い前提を明示して仕切る仕事なのだと、6,848件の後始末から学びました。