赤線で消えた全面切替
全面切替は、計画書の上では美しかったのです。
00:30 に入口を閉じる。
01:10 に向きを変える。
03:00 までに smoke test を終える。
日曜夜の移行演習室で赤線で消されたのは、その美しさの方でした。壁には runbook、endpoint matrix、rollback 条件、依存システム一覧が貼られています。submit_order には緑丸がついていたのに、cancel_order と shipment_callback には赤い付箋が重なり、nightly_settlement の欄には「未移行」の文字が太く残っていました。
ロックさんは、ホワイトボードに貼られた architecture map を前にして、古い箱を消すのではなく、その外側へ細い緑のテープを伸ばしていました。包み込むような線でした。
「外壁を爆破する前に、蔓の伸びる道を決めるべきだったね、ワトソン君」
「工程表にその役割名はありません」
私はそこまで言ってから、壁の赤い付箋へ目を戻しました。
「ですが、言いたいことは分かります。今夜の rehearsal は、バグが一つ見つかったという話ではありません」
実際、submit_order だけを見れば動いていました。Web の注文は新しい RoutingCore へ届いています。問題は裏側でした。cancel は未実装、warehouse callback は旧 status を前提に別ルートで戻り、CSV レポートは旧DBを source of truth にしたままです。入口だけを切り替えても、建物の中で使っている廊下の工事進捗は揃っていませんでした。
「一枚の札で全部の扉を開けようとしたのです」
「結構。それが今夜の事件だよ」
現場検証 - 一枚の切替札に押し込まれた未完成
最初に見せたのは、失敗した rehearsal で実際に使っていた切替の骨組みでした。
Beforeコード: global flag がすべてを一緒に動かす
| |
この設計の狙いは分かります。役員会は「一つの札で戻せること」を求めます。全面切替を掲げるなら、全部を enable_new_stack にぶら下げたくなる。切替責任者の立場から見れば、それは魅力的です。
けれども、魅力的なのは制御できているように見えるからであって、実際に制御できているからではありません。
新しい RoutingCore は submit_order だけは実装済みでした。ところが cancel_order はまだです。
| |
私は腕を組んだまま、言い訳ではなく事実として説明しました。
「submit_order は通りました。だから smoke test では緑がついたんです。ですが、同じ札で cancel_order まで新系へ送れば、未実装のまま落ちます」
ロックさんは cancel_order の行を指で叩きました。
「準備のできた扉も、できていない扉も、同じ鍵で開けようとしたわけだね」
それだけではありませんでした。もっと厄介だったのは、そもそも facade を通っていない廊下が残っていたことです。
Hidden consumer は facade を通らない
| |
warehouse callback は古い status code を吐く前提で、旧系へ直結していました。enable_new_stack を上げても、この handler には何の影響もありません。入口だけを新しくしても、建物の裏口が古いままなら、切替は成立しません。
私はそこでようやく、自分が嫌っていた違和感の名前を口にできました。
「Facade を置いたのに、交通整理がありませんでした」
「その通り。窓口はあった。だが順路表がない」
Big Bang Rewrite が失敗しやすいのは、古いコードが存在するからではありません。未移行の責務、隠れた依存、意味の違う payload を、一夜の切替へ圧縮してしまうからです。問題は legacy の存在ではなく、進捗の違う廊下を一枚の札で同時に開けることでした。
推理披露 - 蔓は壁ごとではなく継ぎ目ごとに伸ばす
「では、全面切替をやめても複雑さが残るだけではありませんか」
私は最初にそこを確認しました。temporary component を増やして、借金の置き場を変えるだけに見えたからです。
「残るとも」
ロックさんは、あっさり認めました。
「違いは、月曜朝の全館停止として噴くか、route table と map に隔離されるかだよ」
この返事で、ようやく話が技術の話になりました。複雑さを消すのではなく、置き場を制御する。それが Strangler Fig でした。
切替単位を system から slice に戻す
まず必要なのは、「何をどこで切るのか」を system 全体ではなく slice 単位へ戻すことです。
| |
ここで大事なのは、クラスの境界ではなく責務の境界です。submit_order:web のように、入口、source of truth、rollback の単位が一緒に決められる切れ目で切る。cancel_order と report_export は readiness が違うのだから、同じ札に乗せてはいけません。
「module ではなく責務単位で切るんですね」
「結構。system を置き換えるのではない。責務の担当を、継ぎ目ごとに移すのだよ」
Strangler Fig は old と new を長く共存させます。だからこそ、共存の単位を狭くしなければなりません。ここで雑に切ると、今度は「安全に小さく失敗する」代わりに「間違いを小分けに量産する」だけになります。
Facade に持たせるのは dispatch だけ
次に、Facade の責務を絞ります。切替期の Facade は必要です。しかし、必要だからといって何でも背負わせると、今度は migration 中の God Class になります。
| |
Facade 自体は、入口を固定し、route decision を一箇所へ寄せるだけです。translation も exposure policy も retirement judgment も、ここへは入れません。
route decision は別オブジェクトへ逃がします。
| |
私はこの分離を見て、ようやく feature flag の居場所も理解しました。flag は migration の計画そのものではありません。準備のできた slice を、今この利用者へ見せてよいかを決めるだけです。切替順序や hidden consumer の棚卸しまで flag に背負わせると、if 文が工程表の代わりを始めます。
役割は次のように分けるのが素直です。
| 要素 | 持つ責務 | 持たせない責務 |
|---|---|---|
| Facade | 安定した入口と dispatch | business rule、semantic translation、retire judgment |
| MigrationDecisions | route selection と exposure 制御 | hidden consumer の一覧管理、翻訳責務 |
| Feature flag | canary、即時退避、露出可否 | migration order そのもの |
| ACL | old/new の語彙と意味の翻訳 | traffic exposure の判断 |
| MigrationMap | slice、依存、exit condition の記録 | runtime if 文の代用 |
ここで初めて、Facade が「単なる窓口」ではなく「交通整理役」になります。整理役であり続ける条件は、荷物の中身まで抱え込まないことでした。
ACL は移行途中の新館を守る防波堤になる
「ACL は前回の話でした。今回は route の問題なのに、まだ必要ですか」
私がそう聞くと、ロックさんは warehouse callback の票を一枚持ち上げました。
「route が決めるのは、どちらへ送るかだ。ACL が守るのは、新しい側がどの言葉で考えるかだよ」
今回の RoutingCore は、新しい建物です。けれども移行の途中では、まだ legacy の callback や status code と接続せざるを得ません。そのとき、新しい側が old vocabulary で育ってしまうと、置換したはずなのに新館の中で古い都合が生き延びます。
| |
この ACL がやっていることは派手ではありません。L-2001 を 2001 に直し、S を shipped に直しているだけです。ですが、移行期にはそれで十分です。新しい側が old payload を直接読まなくて済む。つまり route と translation を分けられる。それが大きい。
私はここで、前夜の rehearsal がなぜ嫌だったのかをもう少し正確に言えるようになりました。私は old system が嫌だったのではありません。new system の中にまで old system の言葉が入り込むことが嫌だったのです。
撤去条件を書かなければ temporary は permanent になる
残る疑問は一つでした。
「temporary component を、いつ消せると判断するんですか」
これに答えられない限り、私はどんな仮設も受け入れられません。Strangler Fig が安全な移行戦略だと言われても、temporary が無期限なら、それは新しい遺構です。
ロックさんは、ここで初めて wall map ではなく、撤去条件の方を指しました。
| |
divergence が残っているか。
rollback が発生していないか。
facade を通らない direct caller が消えたか。
flag を抜いたか。
この四つが揃って初めて、その slice は legacy route を外せます。私はこの関数を見て、ようやく temporary という言葉に期限が入った気がしました。
「一時的という言葉は、撤去条件を持って初めて意味を持つのですね」
「そうだ。期限なき仮設は、ただの新しい古墳だよ」
テストで確認する - 一枚札からカード単位へ
Strangler Fig を説明だけで終わらせると、どうしても「設計の雰囲気がいい話」に見えます。そこで今回は、Before と After をテストで固定しました。
Before では、次の二つが起きます。
enable_new_stackを上げるとsubmit_orderだけでなくcancel_orderまで新系へ送られる- facade を通らない
ShipmentCallbackHandlerが old vocabulary をそのまま運び込む
After では、逆に次の四つが通ります。
submit_order:webだけを new へ route できるcancel_order:webは legacy に残せる- feature flag を下ろせば prepared slice でも legacy へ戻せる
- callback payload の translation と temporary route の retirement を独立に管理できる
この差は小さく見えて、実務では決定的です。smoke test の緑が「全部通った」を意味しなくなります。代わりに「このカードは通った」が言えるようになる。失敗しても、赤く塗り戻すのは建物全体ではなく、そのカードだけで済みます。
flowchart LR
Client[Web Client] --> Facade[FulfillmentFacade]
Facade --> Decisions[MigrationDecisions]
Flags[Feature Flags] --> Decisions
Map[MigrationMap] --> Decisions
Decisions -->|submit_order:web| New[RoutingCorePort]
Decisions -->|cancel_order:web| Legacy[LegacyFulfillmentPort]
Callback[Legacy Callback] --> ACL[LegacyFulfillmentAcl]
ACL --> New
私はこの図を見ながら、ようやく「段階移行」と「先送り」の違いを説明できるようになりました。先送りは、判断を future の cutover に積み残します。段階移行は、判断を今ここで map と telemetry に書き出します。書き出したものだけを、順に消していきます。
壁に残ったのは、撤去条件つきの蔓
話が終わったあと、私は壁の中央から赤いプレートを外しました。
ALL TRAFFIC TO NEW
その代わりに、submit_order:web、cancel_order:web、shipment_callback、report_export と書いたカードを並べました。今夜の時点で緑にできるのは一枚だけです。けれども、それでよいのだと初めて言えました。全部を一度に緑へ変えられないことは、失敗ではありません。順番を持たずに全部を動かそうとしたことが失敗だったのです。
「これなら、切替判定の会議で説明できます」
「説明できる設計は、たいてい戻し方も持っている」
ロックさんは、外した赤いプレートを眺めてから、軽く持ち上げました。
「立派な証拠品だ。次の事件でも、全面切替を正義と呼ぶ者がいたら見せるとしよう」
私は笑いませんでした。あれは冗談というより、運用ルールでした。
全面切替の一行は消えました。壁に残ったのは、ようやく現実の順序で並んだ切替のカードでした。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| 全 operation を一枚の全面切替フラグに押し込むこと | Strangler Fig による slice 単位の段階移行 | 失敗半径を system 全体から slice 単位へ縮められる |
| route、flag、translation を facade 一つへ混載すること | Facade + MigrationDecisions + ACL の責務分離 | 移行中の God Class 化を防げる |
| hidden consumer を棚卸ししないこと | MigrationMap による依存の見える化 | smoke test の外にある依存を切替前に扱える |
| temporary component に期限を付けないこと | exit condition による retirement 判断 | 仮設を legacy と一緒に永住させずに済む |
推理のステップ
- facade を通らない hidden consumer を含め、入口と裏口を棚卸しする
- system 全体ではなく capability / channel 単位で migration slice を定義する
- stable な facade を置き、dispatch だけを一箇所へ集約する
- route decision と feature flag を分け、露出制御を if 文の沼にしない
- 新しい側が old vocabulary で汚れないよう ACL を seam に置く
- divergence、rollback、direct caller、flag 削除を exit condition として記録する
- 緑になった slice から順に legacy route と temporary component を撤去する
ロックより
古い館を壊すなと言っているのではない。壊す順を誤るなと言っているのだよ、ワトソン君。一夜で外壁を吹き飛ばせば、支えていた蔓まで失われる。ならば逆に、新しい蔓を一枝ずつ伸ばしたまえ。
route を決める者、言葉を翻訳する者、退出条件を書く者。その三者を同じ机へ縛りつけるな。役を分けておけば、崩れるのは建物全体ではなく一本の枝で済む。移行とは、正しい順番で縮小する混沌なのだよ。
