Disposability
「使い捨てに作る」はかつて手抜きの言い換えだったが、12-Factor App (2011) 以降、規模と回復のための設計要件として読み替えられるようになった。プロセスを速く起動して速く止められること、サーバをいつでも作り直せること、これらは雑さではなく前提条件である。 生成AI時代になると、捨てたい対象はプロセスやサーバではなく、エージェントが大量に書き換えるコードとデータそのものになる。Feature Flag、ドメインモデル、永続表現、イベント。これらを差分として安全に捨てられる構造にしておかないと、書く速度に対して捨てる速度が追いつかず、使われない分岐と旧モデルがコードベースに残り続ける。
Disposabilityの分類
プロセス・インスタンスのDisposability
12-Factor App "Disposability" — Adam Wiggins, 12factor.net/disposability (2011)。速い起動、SIGTERM での片付け、ワーカーの冪等性、Crash-Only Software への参照を要件として並べる Phoenix Server / Immutable Server — Martin Fowler PhoenixServer (2012) と ImmutableServer (2013)。configuration drift を排除するため、サーバを定期的に作り直し、デプロイ後は変更しないでイメージごと置き換える Kubernetes Pod の lifecycle — Pod Lifecycle。terminationGracePeriodSeconds / preStop hook / ReplicaSet による自動置換でプロセス使い捨てを Pod 単位で運用する Crash-Only Software — Candea & Fox Crash-Only Software (HotOS IX, 2003)。「止め方は crash、起動は recovery」と一本化することで、停止と回復の経路を別に検証する必要をなくす Erlang "Let it crash" — Joe Armstrong PhD Thesis (2003)。軽量プロセス + Supervisor tree でプロセスを安価に作り、回復は監督者に任せる アーキテクチャの Disposability
「全体を捨てる前提で作る」という設計戦略
Plan to throw one away — Brooks 1975『The Mythical Man-Month』Ch.11。"Hence, plan to throw one away; you will, anyhow." 20周年版で漸進的成長 (incremental development) に主張を置き直している Sacrificial Architecture — Martin Fowler SacrificialArchitecture (2014)。eBay の Perl → C++ → Java → microservices の遷移と Jeff Dean "design for ~10X growth, but plan to rewrite before ~100X" を引きつつ、「Sacrificial だからといって内部品質を犠牲にしてはならない」という警告も置く 機能のDisposability
最も粒度の細かい層。Feature Flag を入口に、ドメインモデル・永続表現・イベント・観測コードを差分として捨てられるように設計する。
Feature Toggle Dynamics in Large-Scale Systems — arXiv:2604.15872。Kubernetes と GitLab の複数年データから、toggle の追加に対して削除が遅れること、生存期間の中央値が Kubernetes 734日、GitLab 185日、といった非対称を報告している Schema Evolution / Event Upcasting — Event Sourcing で過去のイベントを書き換えず、読み出し時に upcaster で現在の形式へ変換する技法
Expand-Contract DB Migration — Parallel Change の DB 版。後方互換のカラム追加 → 新旧並行運用 → 旧カラム削除の三段階
共通する原則
呼称が違っても、これらの議論には粒度を超えて繰り返し現れる設計条件がある。
境界での再構築可能性
Phoenix / Immutable Server、Kubernetes Pod、Sacrificial Architecture、Branch by Abstraction はいずれも、中身を保持せず外から作り直せる単位を境界として切る、という同じことを別粒度で言っている。
状態の外部化
12-Factor の状態を持たないプロセス、Heroku の永続化されないファイルシステム、後段で扱う永続化境界での解釈の遅延、いずれも生き残らせたい状態を外に追い出す構造になっている。
起動と停止の対称性
12-Factor が要求する速い起動と片付けのある停止、Erlang プロセスの軽量さ、Lambda の cold start (起動時の遅延) が性能に直結すること、これらは入口と出口が対称に速くないと捨てるという選択そのものが採れない、という同じ要請を別の場所で受けている。
Candea & Fox の Crash-Only、Armstrong の Let it crash、Chaos Monkey による事前破壊が共通して採るのは、起動と回復を同じパスに重ねる単純化で、止め方と回復経路を別々に検証する必要をなくすための処方になっている。
書き戻しの非対称性
Event Sourcing の upcasting、後方互換 API、後段で扱う永続化境界のバージョン付き解釈は、いずれも読み込みでは過去互換を許容し、書き込みは現在形で行う、という入出力の非対称性を採る。
削除可能性
Parnas 1979 が縮小 (contraction) を拡張 (extension) と並べたように、削除を拡張と同じ重みで設計対象にする。使われなくなった flag の片付けの議論も、削除を後回しにすると設計が壊れることを別の角度から示している。
合成可能性
Branch by Abstraction、Parallel Change、Composable Domain Model、合成可能なデコーダーは、丸ごとコピーして並走させる代わりに、変化点だけを差し替え可能な部品として切り出す。捨てる単位を機能全体ではなく差分そのものに揃えるための条件で、これがないと「いずれ捨てる」と書かれた塊が捨てられないまま残る。
粒度ごとに支配的になる条件は違う。プロセスやサーバでは境界での再構築可能性と起動・停止の対称性が運用の成否を決める。機能差分の層では、合成可能性・状態の外部化・書き戻しの非対称性・削除可能性の四つが支配的になる。
機能のDisposabilityの設計
プロセスやサーバの Disposability は、コンテナランタイムやオーケストレータが大部分を引き受けてくれる。アプリ側で考えることは、SIGTERM を受けたら進行中の処理を片付けて止まる、ローカルファイルを当てにしない、起動を速くする、くらいに限られる。
機能のDisposabilityにはそういう肩代わりがない。Feature Flag を一つ足すと、ドメインモデルにも、入力や DB レコードの読み方にも、保存形式にも、イベントの形にも、メトリクスやログにも、複数の実装が並ぶことになる。Feature Flag の使い方や、使われなくなったフラグの削除は個別には語られているが、これらを「ひとまとまりの差分」として捨てやすい設計は、あまり語られていない。
定義
機能のDisposabilityを以下のように定義する。
機能のDisposabilityとは、ある機能差分を導入・検証・段階展開・固定・削除する過程で、不要になった実装部品、モデル部品、入出力解釈、永続表現、フラグ、メトリクスを安全に廃棄できること。
廃棄単位を「機能全体」と取ると粒度が粗すぎる。実際に捨てる対象はより細かい。
捨てる単位を細かく取ろうにも、実際には難しいことが多い。例として注文ドメインを取る。v1 の Order は、注文者と明細と、配送先住所と、請求書払いを前提とした請求情報からなる。v2 ではここに二つの変更が入る。配送先は単一住所ではなく、店頭受け取りや分割配送を含む受取計画として扱う。請求は請求書払いだけでなく、クレジットカードや口座振替を含む請求プロファイルとして扱う。注文の ID・注文者・明細・金額・数量といった残りの部分は v1 と v2 で同じである。
ドメインモデルがない実装では、Order が一枚岩のクラスとして定義され、配送先や請求情報は Order のフィールドとして直接定義される。v1 から v2 への変更は、Order に新しいフィールドを足し、boolean フラグや null 判定で v1/v2 の挙動を切り替える形で入る。
code: (java)
class Order {
OrderId id;
Buyer buyer;
List<OrderItem> items;
// v1: 配送先住所
String shippingPostalCode;
String shippingAddress;
// v2: 受取計画
boolean useFulfillmentPlan;
String fulfillmentKind; // "delivery" / "store_pickup" / "split"
List<DeliverySlot> slots;
// v1: 請求書払い
String invoiceCompanyName;
String invoiceTaxId;
// v2: 請求プロファイル
boolean useBillingProfile;
String paymentMethodKind; // "credit_card" / "bank_transfer" / "invoice"
String paymentMethodToken;
}
submit や validate の中には if (order.useFulfillmentPlan) { ... } else { ... } のような分岐が散らばる。A/B テストで v2 の指標が悪く廃案にする、つまり v2 を捨てて v1 に戻すときには、これらの分岐とフィールドをコードベースから一つひとつ探して削る必要がある。何が v2 のために足された部品で、何が共通の部品なのかは、Order のフィールド一覧と分岐を読み解くまで分からない。
Orderをv1用とは別に、v2用を作ることも考えられる。
code: (java)
class Order {
String id;
String buyerId;
List<OrderItemRow> items;
String shippingPostalCode;
String shippingAddress;
String invoiceCompanyName;
String invoiceTaxId;
}
class OrderV2 {
String id;
String buyerId;
List<OrderItemRow> items;
String fulfillmentKind;
List<DeliverySlotRow> slots;
String paymentMethodKind;
String paymentMethodToken;
}
一枚岩の Order に boolean フラグを足し続けるのに比べれば、v1 と v2 のフィールドが同じクラスで混ざらないぶん、submit や validate の中の if (useFulfillmentPlan) のような小さな分岐は消える。v2 を廃案にするときは OrderV2 と SubmitOrderV2 を消せばよい、と読める。
ただし、消えた小さな分岐の代わりに SubmitOrder と SubmitOrderV2 という大きな分岐ができる。在庫チェック、注文者の与信、合計金額の計算といった v1 と v2 で共通であるはずの処理が、両方のユースケースクラスに重複して書かれる。items の合計金額計算ロジックを修正したいとき、SubmitOrder 側だけ直して SubmitOrderV2 側を直し忘れることが起きる。コピーされた処理は時間とともに食い違っていき、どちらが正しいのかの根拠はコードベース上に残らない。
ここから先は、こうした丸ごとコピーをどう合成可能な形に組み替えるか、ドメインモデル・振る舞い・入出力境界・永続表現を順に見ていく。
Composable Domain Model
Sandi Metz の "The Wrong Abstraction" で広まった "duplication is far cheaper than the wrong abstraction" を disposability にそのまま当てて、Francesco Strazzullo は Sacrificial Architecture in Web Development で「捨てやすく保つには重複を受け入れろ」と書いている。捨てる単位を独立に保つには共有を増やすなというのは、抽象が見つかっていない局面では正しい。だが先の注文ドメインの例で見たとおり、これを v1/v2 に機械的に当てると共通部品まで複製され、差分が判別できなくなる。重複は、変化点が意味として切り出せていない段階での暫定解であって、切り出せた後にそのまま据え置くものではない。 コピーではなく合成として書くには、その前に「v1でもv2でも同じでなければならないデータと振る舞い」と「変化する軸」を見分けておく必要がある。これを格納する場所がドメインモデルになる。コピーから始めるとこの分離が後回しになり、共通部品の差分が偶然のコピー差なのか本質的な差なのかを後から復元することは難しい。
注文ドメインで言えば、Order を「ID と注文者と明細と Fulfillment と Billing から組み立てたもの」として書き、Fulfillment や Billing をそれぞれ独立した型として扱う。すると結果として変化しない部分 (OrderId、Buyer、OrderItem、Money、Quantity) と、変化する部分 (Fulfillment、Billing) が分離して見えてくる。
code: (text)
変化しない部分:
OrderId, Buyer, OrderItem, Money, Quantity
変化する部分:
Fulfillment
- ShippingAddressFulfillment
- FulfillmentPlan (Delivery / StorePickup / SplitDelivery)
Billing
- LegacyBilling
- BillingProfile
この分離は事前に「ここは変わる、ここは変わらない」と決めて切り出すのではなく、合成として書くことで現れる。変化する部分は型パラメータや sealed interface として差し替え可能になる。
code: (java)
record Order<F extends Fulfillment, B extends Billing>(
OrderId id,
Buyer buyer,
List<OrderItem> items,
F fulfillment,
B billing
) {}
ただし、コピーを避けたいからといって、意味の違う概念を無理に共通の上位型に押し込んではならない。
table:table
状況 設計
一方の概念がもう一方の特殊ケース 共通の上位型にできる
不変条件が異なる 別のモデル部品として分ける
入力形式だけが違う 入力解釈の境界だけ分ける
ユースケースの流れが違う ユースケースを別部品に分ける
永続表現だけが違う DB との読み書きの境界だけ分ける
ここを誤ると「コピーしない」ことが目的化し、Optional と boolean フラグだらけのドメインモデルが生まれる。合成可能に分けること自体ではなく、変化点を意味的に切り出すことが本来の作業である。
データを Order<F, B> に分解しても、最初に挙げた SubmitOrder と SubmitOrderV2 の中身はまだコピーのまま残る。在庫チェック・与信・合計計算といった共通処理と、Fulfillment 種別ごとの出荷指示生成、Billing 種別ごとの請求実行を、振る舞い側でも合成できる形に分けておく必要がある。
振る舞いを Function / BiFunction の実装として書き、結果は Result で受ける。共通の振る舞いは variant に依らず使い回せる形に書く。
code: (java)
// 共通: variant に依らない
public class CalculateTotal
implements Function<Order<? extends Fulfillment, ? extends Billing>, Result<Money>> {
public Result<Money> apply(Order<? extends Fulfillment, ? extends Billing> order) { ... }
}
public class CheckInventory<F extends Fulfillment, B extends Billing>
implements Function<Order<F, B>, Result<Order<F, B>>> {
public CheckInventory(InventoryGateway inventory) { ... }
public Result<Order<F, B>> apply(Order<F, B> order) { ... }
}
variant 固有の振る舞いは sealed 型の値を switch のパターンマッチで分岐する。新しい Fulfillment 種別が permits に増えると、コンパイラが網羅性チェックで未実装の枝を検出する。
code: (java)
public class IssueFulfillmentInstruction
implements Function<FulfillmentPlan, Result<FulfillmentInstruction>> {
public Result<FulfillmentInstruction> apply(FulfillmentPlan plan) {
return switch (plan) {
case Delivery d -> Result.ok(new ShippingInstruction(...));
case StorePickup s -> Result.ok(new StorePickupInstruction(...));
case SplitDelivery s -> Result.ok(new SplitInstruction(...));
};
}
}
ユースケースは flatMap でこれらを直列につなぐだけになる。
code: (java)
public Result<PlacedOrder> apply(Order<F, B> order) {
return checkInventory.apply(order).flatMap(checked ->
calculateTotal.apply(checked).flatMap(total ->
issueInstruction.apply(checked.fulfillment()).flatMap(instruction ->
charge.apply(checked.billing(), total).map(receipt ->
new PlacedOrder(checked.id(), instruction, receipt)))));
}
新しい Fulfillment 種別を増やしたいときに変更が要るのは IssueFulfillmentInstruction の switch だけで、CheckInventory も CalculateTotal もそのまま使える。Fulfillment 種別を捨てるときは、その枝と対応するデータ型・データ移行に閉じる。SubmitOrder を v1/v2 で丸ごとコピーした場合との違いは、共通処理の修正が片側に閉じる点と、捨てる単位が「sealed の枝1つとそれに紐づく振る舞い1つ」まで小さくなる点になる。
Composable Decoder と Compatibility Decoder
HTTP の JSON、CSV、DB レコードのような外部表現を受け取り、ドメイン型 (不変条件を満たす値) に変換する境界の関数をデコーダーと呼ぶ。一般的な OO 設計や Clean Architecture では DTO + Mapper、ORM 経由、Bean Validation 後にエンティティへ詰め替えるといった形で散らばっており、「decoder」という単一の名前で呼ぶ習慣は薄かもしれない。Alexis King Parse, don't validate や Scott Wlaschin の Domain Modeling Made Functional 系の流派が、この境界の関数を一級の設計対象として扱い、「decoder」「parser」と呼ぶ。後段の議論はこの語彙を借りて進める。出力側で同じ役割を果たす関数はエンコーダーと呼ぶ。 以降のコード例は拙作の Raoh で書く。JSON、Map、jOOQ の Record を入力に取れるデコーダーを combine / field / discriminate / map という同じ語彙で組み立てるライブラリで、JSON 境界・永続化境界・イベント境界を1つの語彙で扱えるのが、ここでの議論に必要な性質である。デコーダー合成の動機と詳細は『古典ドメインモデルパターンの解脱』にまとめた。 同じ合成可能の議論がデコーダーにも当てはまる。OrderV1Decoder と OrderV2Decoder を中身ほぼ同じで2つ持つのは、単なるコピーである。デコーダーも合成可能に分解する。
code: (text)
OrderDecoder =
orderIdDecoder
+ buyerDecoder
+ itemListDecoder
+ fulfillmentDecoder
+ billingDecoder
差分は fulfillmentDecoder だけに閉じる。
ここで一段、設計の判断を入れる。旧プロトコルを残す必要があることと、旧ドメインモデルを残す必要があることは別だ。shipping_address という旧JSON キーを受け続ける必要があっても、ドメイン側を ShippingAddressFulfillment という旧型のまま流す必要はない。境界のデコーダーで、旧プロトコルを受け取りながらドメイン側は現行の FulfillmentPlan に変換してしまえる。
これを互換デコーダーと呼ぶ。
code: (java)
JsonDecoder<FulfillmentPlan> legacyShippingAddressAsFulfillmentPlan =
combine(
field("postal_code", string()),
field("address", string())
).map(LegacyShippingAddress::new)
.map(FulfillmentPlan::fromLegacyShippingAddress);
LegacyShippingAddress は decoder の中だけで一瞬使われる中間型で、ドメインの外には出ない。
互換 decoder を v1 decoder の fulfillment 部品として差し込めば、v1 入力もドメイン側は現行モデル (Order<FulfillmentPlan, ...>) に揃う。
code: (java)
import static net.unit8.raoh.json.JsonDecoders.*;
JsonDecoder<Order<FulfillmentPlan, LegacyBilling>> orderV1Decoder =
combine(
field("id", orderIdDecoder),
field("buyer", buyerDecoder),
field("items", list(orderItemDecoder)),
field("fulfillment", legacyShippingAddressAsFulfillmentPlan),
field("billing", legacyBillingDecoder)
).map(Order::new);
JsonDecoder<Order<FulfillmentPlan, BillingProfile>> orderV2Decoder =
combine(
field("id", orderIdDecoder),
field("buyer", buyerDecoder),
field("items", list(orderItemDecoder)),
field("fulfillment", fulfillmentPlanDecoder),
field("billing", billingProfileDecoder)
).map(Order::new);
差し替わっているのは fulfillment と billing に渡す decoder だけで、orderIdDecoder、buyerDecoder、orderItemDecoder は v1/v2 で共通である。捨てる対象は orderV1Decoder 全体ではなく、legacyShippingAddressAsFulfillmentPlan や legacyBillingDecoder という個別の部品である。共通部品は捨てない。これは Parse, don't validate 系の思想と整合する。境界で型として定義し、不正な状態をドメインモデルに入れない、という路線で書かれた decoder は元々合成可能に書かれているので、差分の局所化に向いている。
ドメイン側の型を見ると、v1 でも v2 でも Order<FulfillmentPlan, ?> で揃っている。ShippingAddressFulfillment という旧型はどこにも現れない。プロトコル互換の維持と旧モデルの維持が混ざりがちなのを、decoder の境界で分離した結果になる。Fulfillment の sealed 階層から ShippingAddressFulfillment を外すという削除も、互換 decoder の中身を書き換えるだけで済む。
Versioned DB Decoder と Schema-on-Read 境界
ドメインモデルの変更はデコーダーだけでは閉じない。永続化されたデータも別の表現で残っている。通常はこうなる。
code: (text)
DB の行 ─ ORM/Mapper ─ 現在のドメインモデル
この直結だと、ドメインモデルが変わるたびに DB スキーマも現在形に揃えたくなり、既存データの一括 UPDATE が必要になる。これが Feature Flag による段階デプロイや切り戻しと衝突する。コードは新旧を切り替えられても、データを一度変換すると旧コードが読めなくなるからである。
代替は、DB の行を「バージョン付き永続表現」として扱うことだ。
code: (text)
DB の行 + representation_version
↓
バージョン別の DB decoder
↓
現在のドメインモデル
code: (sql)
ALTER TABLE orders
ADD COLUMN representation_version integer NOT NULL DEFAULT 1;
Raoh の jOOQ extension で書くと、行を読むときに representation_version を見てデコーダーを選ぶ形が素直に書ける。discriminate は「識別子を取り出して、その値で後続 decoder を選び、同じ record を後続デコーダーに再適用する」働きをするので、まさにこの場面のための道具になる。
code: (java)
import org.jooq.Record;
import static net.unit8.raoh.decode.Decoders.discriminate;
import static net.unit8.raoh.decode.ObjectDecoders.int_;
import static net.unit8.raoh.jooq.JooqRecordDecoders.field;
Decoder<Record, Order<Fulfillment, Billing>> orderRowDecoder = discriminate(
"representation_version",
field("representation_version", int_()).map(String::valueOf),
Map.of(
"1", orderRowDecoderV1ToCurrent,
"2", orderRowDecoderV2ToCurrent,
"3", orderRowDecoderV3
));
読み出し側は Order<Fulfillment, Billing> という型 (型パラメータを上位型で受けた状態) で扱う。decoder 側で持っていた Order<FulfillmentPlan, LegacyBilling> のような具体的な型情報は永続化境界を越える時点で消えるが、sealed の枝はコンパイル時に列挙されているので、後段のパターンマッチで網羅性が取れる。未対応の representation_version に対するエラーは discriminate が NOT_ALLOWED として返すので、片付け予定の旧バージョンが残っているときの検出にもそのまま使える。
書き込みはエンコーダー側を現在形に固定する。
code: (java)
import static net.unit8.raoh.encode.MapEncoders.*;
import static net.unit8.raoh.encode.ObjectEncoders.*;
Encoder<Order<? extends Fulfillment, ? extends Billing>, Map<String, Object>> orderRowEncoder = object(
property("representation_version", o -> 3, int_()),
property("id", Order::id, orderIdEncoder),
property("buyer", Order::buyer, buyerEncoder),
property("fulfillment", Order::fulfillment, fulfillmentPlanEncoder),
property("billing", Order::billing, billingProfileEncoder)
);
入力型を Order<? extends Fulfillment, ? extends Billing> と書いておくと、書き込み時に v1 / v2 / 互換デコーダーのいずれを通って入ってきた Order でも同じエンコーダーで受けられる。
同型の設計はいくつかの文脈で別名で呼ばれている。RDB の行を対象にした正面からの呼び名は定着していないが、それぞれの層で同じ非対称性が再発見されている。
Schema Versioning Pattern — MongoDB の公式パターン。各ドキュメントに schema_version を持たせて読み出し側で振り分ける書き方を直接示す (MongoDB Docs) API versioning with transformation chains — Stripe が API レスポンスに対して、バージョン別の変換モジュールを連ねて旧クライアントへ翻訳する仕組みを公開している (Stripe Blog) これは RDB を捨てて Schema-on-Read 化するという話ではない。物理スキーマは書き込み時に固定したまま、行の解釈だけを読み出し時に決める。RDB の制約・インデックス・トランザクションは残し、変化しやすい表現差分だけをデコーダーの境界で扱う。representation_version は集約 (Order、Customer、Invoice など) ごとの行単位の値として持たせる。 注意したいのは、representation_version は行レイアウトの世代番号であって、業務上の種別を表す軸ではないこと。「v3 のレイアウトを保ちつつ、その行が legacy_billing を持つか billing_profile を持つか」のような業務 variant は、representation_version=3 の中で billing_kind のような別カラムとして直交的に表現する。混ぜると「v3 だから billing は profile に揃っているはず」のような暗黙の前提が読み手に紛れ込む。
バージョン別の DB decoder はデータマイグレーションを消すわけではなく、Ambler と Sadalage『Refactoring Databases』(2006) や Valentina Servile『Continuous Deployment』(O'Reilly, 2024) の expand/contract 手順を遅延・分割する技法として位置づく。データマイグレーションを「正しく動くための必須作業」から「古い decoder と旧表現を捨てるための片付け作業」に変えるのが本来の意義になる。 v1/v2 という名前は何を表しているか
ここまでの例は素直に読めば違和感があるはずだ。OrderV1Decoder / OrderV2Decoder、representation_version=1/2/3、checkout_v2 という flag、FullCheckoutV2 という variant 型。合成可能性を主張しながら、肝心の名前が時系列ラベルのまま残っている。
新旧の比較を「v1 と v2」で語るのは、変化を時間軸の上で並べる自然な言い方なので、最初は誰でもそう書くだろう。だが分解が済んで変化点が Fulfillment や Billing という意味のある単位として取り出された後は、変わったのは時刻ではなく Fulfillment や Billing の意味そのものなので、本来の名前はそちらに付くはずである。
Decoder は orderV1Decoder / orderV2Decoder ではなく、orderWithShippingAddressDecoder / orderWithFulfillmentPlanDecoder
永続表現の識別は representation_version という汎用の番号ではなく、その行がどの Fulfillment 種別・どの Billing 種別を持つかを示す fulfillment_kind / billing_kind
Feature Flag は checkout_v2 ではなく fulfillment_plan_rollout のように、何の差し替えを段階展開しているかが名前に出る
variant は FullCheckoutV2 ではなく FulfillmentPlanWithBillingProfileCheckout のように、組み合わせがそのまま名前になる
v 番号は本来「まだ意味を切り出せていない暫定ラベル」であって、合成可能に分解できた瞬間にその役目を終える。
Event Upcasting / Message Schema Evolution
DB と同型の議論は Event Sourcing にも当てはまる。過去のイベントは不変なので書き換えられない。代わりに、読み出し時に upcaster が新しい形式へ変換する。
code: (text)
旧イベント ─ upcaster ─ 現在のイベント / 現在のドメインコマンド
イベント駆動の機能で Disposability を保つには、イベントのスキーマバージョン、イベントの読み出しと upcaster、読みモデルの構築、現在のドメインモデル、コンシューマ側との互換性、これらを分離する必要がある。upcaster は DB の decoder と同型で、「旧イベントが残ること」と「旧ドメインモデルが残ること」は同じではない。
DB の行、API の入力、イベントはいずれも「バージョン付き外部表現」として同じ枠組みで扱える。decoder と upcaster を境界に置くことで、外部表現の寿命とドメインモデルの寿命を分離する。
ここまでで、機能差分を構造的に捨てられる形に分解する話を扱った。残りは何を根拠にいつ捨てるかという判断側の話で、影実行 (Shadow Execution)、variant の型化、使われなくなった flag の片付けの順に見ていく。
Progressive Delivery と Shadow Execution
機能を捨てやすくするには、公開前に「捨てる判断」ができる必要がある。dark launch / shadow traffic は、本番のリクエストを新バージョンへコピーし、レスポンスをユーザーへ返さず正しさ・遅延・コストを観測する技法として知られている。
code: (text)
本番経路: リクエスト → v1 → レスポンス
影経路: リクエストの複製 → v2 → 差分 / メトリクス / ログ (返さない)
この構造により、v2 を本番負荷で検証し、結果が不味ければ v2 のドメイン部品を捨てられる。影実行は安全な段階展開のための技法であると同時に、廃棄判断のための観測技法でもある。捨てる根拠をデータとして集めるのが目的で、観測のためのコード自体も使い捨てに保つ必要がある (本番運用に影経路を残し続けると、それ自体が負債になる)。
Variant の型化
ここまで Feature Flag を、デコーダーや永続表現のどの実装で機能を実行するかを選ぶスイッチとして扱ってきた。flag が増えると、選ばれる組み合わせの数も増え、new_checkout new_coupon new_tax new_fulfillment new_billing のような boolean を if flagA && !flagB で組み合わせると、無効な組合せまで型システムから見えてしまう。
ここで boolean のまま渡さず、許される variant を sealed interface で型として表現する。
code: (java)
sealed interface CheckoutVariant
permits LegacyCheckout,
NewFulfillmentCheckout,
FullCheckoutV2 {}
variant 同士の制約も明示できる。
code: (text)
NewCoupon requires NewCheckout
LegacyInvoice excludes NewBilling
Disposability を壊すのは flag の数そのものではなく、意味づけされていない組合せである。flag を管理するのではなく、許される variant を設計する、という方向に持っていくことで、捨てる単位が「有効な variant の差分」として現れる。
使われなくなった flag を片付ける
Feature Flag は放置すると長命化する。これは感覚論ではなく、実証研究の数値で示されている。Kubernetes と GitLab の数千件の flag 切り替え事象を解析した研究では、削除が追加に遅れ在庫が増え続けること、生存期間の中央値が Kubernetes で 734 日、GitLab で 185 日であること、一部の flag は事実上恒久化していることが報告されている (arXiv:2604.15872)。 「あとで消す」は設計ではない。削除を機械化するための構造が必要になる。Uber の Piranha は使われなくなった feature flag に紐づくコードを AST 変換で自動削除するツールで、Objective-C / Java / Swift に対応する。Java 版は Error Prone のプラグインとして実装されている。Uber 自身は約2000の使われなくなった flag とその関連コードの削除に Piranha を使ったとしている。 Disposability の観点では、Piranha の対象を if(flag) の枝だけに限定しない方がよい。flag が所有するものは枝のコードだけではない。
code: (text)
flag: checkout_fulfillment_v2
所有するもの:
- 旧 shipping_address の読み出し
- 旧 ShippingAddress の decoder
- v1 永続表現の DB decoder
- 段階展開中の差分計測
- fulfillment_v1 のカラム
- v1 イベントの upcaster
削除条件:
- representation_version=1 の行が 0 件
- 旧入力形式での流入が 30 日間 0 件
- 影経路の差分が閾値以下
- すべてのコンシューマ側が v2 に対応済み
ここまでメタデータ化されて、flag が何を所有し、何が満たされたら削除してよいかが、ようやく flag 宣言と同じリポジトリに書ける状態になる。
ただし、削除条件のうち representation_version=1 の行が 0 件 のように永続データの状態を要求するものは、待っているだけでは満たされない。書き込み時に現在形で書く設計でも、読まれない・更新されない行は旧表現のまま残り続けるからだ。バックグラウンドのパージや一括 UPDATE といった片付け作業を別途走らせて初めて条件が揃う。前節の「データマイグレーションを必須作業から片付け作業に変える」とは、削除そのものをなくすという意味ではなく、削除のタイミングを切り戻し可能性と切り離せる、という意味になる。
観測コード (Shadow Execution の差分計測、representation_version ごとの行数集計、段階展開の到達率モニタ) も flag が生きている間だけ意味を持つ一時的なコードで、これを本番のドメインロジックに直接書くと flag が消えても観測コードだけ取り残される。flag 単位のディレクトリに切って、メトリクス名やログのキーに flag 名を含めておくと、片付けのときに grep で残骸を辿れる。観測コードの整備自体が新しい投機的 Disposability になりうる点には注意が要る。一度も差分が出なかった計測、誰も見ないダッシュボードは、捨てる判断のために入れた装置がそれ自体捨てられずに居座る。後段で扱う通り、Disposability の道具は入れた瞬間にコストが発生し、その道具自体に削除条件が要る。
Disposable と呼ばれているが捨てられないもの
捨てるための外形は整っているのに、実際には捨てられない設計の例を並べる。共通する原則の節で機能差分の層で支配的だと挙げた四条件 (合成可能性、状態の外部化、書き戻しの非対称性、削除可能性) のどれかが欠けていることが、捨てられない直接の原因になっている。
v1/v2 の丸ごと並走
order/v1/ と order/v2/ をパッケージごとコピーして並走させ、片方を最終的に削除する設計。一見すると「v1 を消せば使い捨てになる」ように見えるが、共通部品まで複製されているので、差分が意味的に切り出されていない。捨てる対象が「v1 全体」と粗いままなので、共通部分の修正が両側に必要になり、削除に踏み切れる時点が遠ざかる。
欠けている前提: 合成可能性 (変化点が意味として切り出されていない)。
状態を持った「使い捨て Pod」
Pod が使い捨てである前提でローカルディスクやメモリ内キャッシュに本物の状態を持たせると、Pod 単体は家畜に見えても実態はペットに近い。「使い捨て」と書かれた YAML マニフェストは Disposability を保証しない。emptyDir や PersistentVolume に書く場合に「これが消えても困らないか」を都度問い直さないと、ある日の Pod 再起動で挙動が変わるシステムが残る。
欠けている前提: 状態の外部化 (生き残らせたい状態を Pod の中に持っている)。
永続化された Feature Flag
Feature Flag は本来、機能差分を流動的に切り替えるための一時的な装置である。しかし実証研究の数値が示す通り、削除は追加に系統的に遅れる。Kubernetes で中央値 734 日、GitLab で 185 日という生存期間は、flag の半数以上が「短期に消す」予定通りには消えていないことを示す。flag を入れた瞬間に「この flag はいつ何が満たされたら削除する」という条件をセットでコード化しなければ、flag は恒久的な分岐に変質する。
欠けている前提: 削除可能性 (削除条件がメタデータ化されていない)。
データを一括変換しないと動かないマイグレーション
新コードがデプロイされた瞬間に旧データを全変換しなければ動かない設計は、切り戻しの経路を持たない。コードは使い捨てに切り替えられても、データは使い捨てではなくなる。途中失敗の整合性、ロックの長期保持、旧コードとの互換性喪失、いずれも「捨てる」ための前提を壊す。
欠けている前提: 書き戻しの非対称性 (読み込みは過去互換、書き込みは現在形、という分担を採っていない)。
Disposability の落とし穴
前節は Disposable と誤認される設計を扱った。ここでは、Disposability を正しく追求しているつもりでも踏みやすい落とし穴を3つ挙げる。合成可能性・バージョン付き解釈・削除条件のいずれも、入れた瞬間にコストが発生する。捨てるために入れた境界・メタデータ・観測コードがそのまま残ると、それ自体が新しい負債になる。
投機的な Disposability
「将来捨てるため」と称して入れた抽象が、実際には捨てる対象にならず複雑性だけが残る現象。柔軟性と汎用性 の Speculative Generality と同型で、定義域だけ広げて事後条件・実装非依存性の裏付けがないと、見た目の柔軟性が増えて中身が壊れる。Composable Domain Model の節で、コピーを避けたいからといって意味の違う概念を共通の上位型に押し込むなと書いたのと同じ構図だ。「いつでも差し替え可能」を謳って入れた sealed interface や型パラメータが、実際には差し替えられたことがないなら、それは捨てるための境界ではなく余計な間接化である。使い捨ての境界は、実際に捨てたことがあるか、捨てる条件が書けているか、で判定するのが安全である。 退却可能性とのトレードオフ
家畜として扱うために、個別インスタンスのデバッグや事後解析がしにくくなる。Phoenix Server / Immutable Server は設定の食い違いの蓄積を排除する代わりに、特定インスタンスにログインして調査するという旧来の運用ができなくなる。同じ構図は機能差分層にもある。Shadow Execution で本番負荷の差分を集める、Versioned DB Decoder で representation_version ごとの分布を見る、いずれも中央ログ集約とトレーシングが整っていなければ捨てる根拠を集められない。Disposability を取るなら、粒度を問わず観測基盤を別投資として持つ必要がある。
Sacrificial の言い訳化
Fowler 自身が SacrificialArchitecture で警告した通り、「どうせ捨てるから」は内部品質を犠牲にする言い訳にはならない。捨てる経路を保つには、捨てるまでの間に共通部品の修正が両側に伝播しないこと、削除条件が満たされたかを観測できること、片付け作業が機械化されていることが要る。Composable Domain Model・representation_version・Piranha 的なメタデータ化は、すべて「捨てる前提だから雑に書いてよい」と両立しない。Disposable に作るというのは「いずれ捨てる」と言うこととは違って、捨てる経路を保ったまま現在の作業ができる状態を維持することを指す。