45395 - シコウサクゴ -

新旧フォーマット混在を『分岐』ではなく『不変条件1行』で吸収する:6,848件エラーの後始末から学んだ後方互換の型

2026-05-21
AI駆動開発
AI駆動開発
Claude Code
後方互換
リファクタリング
データ設計
Last updated:2026-06-08
12 Minutes
2295 Words

ある日、上流から流れてくるデータの識別子フォーマットが変わりました。それまで「5桁の数字」だったものに、「4桁の英数字 + 末尾1文字」という新形式が混ざり始めたのです。

既存の正規化ロジックは旧形式しか想定していませんでした。結果、新形式の 446A0446A に誤って短縮し、6,848件のレコードが一括でエラーフラグ付きになりました。

本記事は、この後始末から学んだ「新旧フォーマットの混在を、巨大な分岐ではなく不変条件(invariant)のガード式1行で吸収する」という後方互換の型の記録です。AI agent に修正を依頼するときにも、この型を指示できると修正が一気に小さくなります。

事の発端:正規化関数が新形式を「短縮」していた

問題のコードはこんな正規化関数でした(匿名化しています)。

1
def normalize_record_id(raw: str) -> str:
2
# 末尾の桁を落として親IDに正規化する、という旧来の仕様
3
if raw.endswith("0"):
4
return raw[:-1]
5
return raw

旧形式の識別子は「5桁すべて数字、末尾が 0 なら親IDを表す」という規約でした。123401234 のように末尾を落とすのが正しい正規化です。

ところが新形式 446A0 が来たとき、このコードは末尾の 0 を見て 446A に短縮してしまいました。新形式の 446A0 は「これ全体で1つの識別子」であって、末尾の 0 は親子関係を表すものではなかったのです。

短縮された 446A は当然どのマスタにも存在しません。下流の照合で全部「該当なし」になり、6,848件にエラーフラグが立ったわけです。

最初に却下した修正:新旧で関数を分ける

AI agent に修正を依頼すると、最初はこういう案を出してきました。

1
def 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
7
def 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つのガード式でした。

1
def 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行(ガード式とコメント)だけです。ポイントは、**「新形式を判定して分岐する」のではなく「旧形式だけが満たす不変条件を厳密に書く」**という発想の転換です。

446A0isdigit()False を返すので、このガードには絶対に入りません。新形式を一切「知らない」まま、旧形式だけを安全に処理して、それ以外は素通しするのです。

なぜこれで両立するのか

正規化の意図を分解すると、こうです。

入力旧仕様の意図ガード式の評価結果
12340親IDに正規化5桁・数字・末尾0 → True1234(正しい)
12345そのまま末尾が0でない → False12345(正しい)
446A0そのまま(新形式)isdigit() が False → False446A0(正しい)
4そのまま5桁でない → False4(正しい)

旧形式の「親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. 旧ロジックが正しく動く不変条件を厳密に言語化する
  2. それをガード式1行に落とす(新形式の知識は一切入れない)
  3. 移行が完全に終わったら、そのガードごと削除する

新形式を「どう処理するか」から考えると分岐が増えます。旧形式が「いつ成立するか」から考えると、新形式は自動的にすり抜けます。後方互換の設計は、新しい何かを足すのではなく、古い前提を明示して仕切る仕事なのだと、6,848件の後始末から学びました。

Article title:新旧フォーマット混在を『分岐』ではなく『不変条件1行』で吸収する:6,848件エラーの後始末から学んだ後方互換の型
Article author:45395
Release time:2026-05-21

記事へのご質問・ご感想をお聞かせください

フィードバックを送る