メインコンテンツまでスキップ

第32章:イベントのバージョニング戦略(どう進化させる?)🔖🔢

この章のゴール 🎯✨

  • ドメインイベントを壊さずに進化させるための「バージョンの持ち方」を選べるようになる 🧠✅
  • 変更が入ったときに、移行の手順(段階的に変える流れ)を組めるようになる 🪜🚚
  • TypeScriptで「v1 / v2 を安全に扱う」実装パターン(例)を手元に持てる 🧩🔷

1. まず「互換性」ってなに?💡🔁

イベントは「未来の自分・他機能・別プロセス」が読む**契約(Contract)**だったよね 🤝📜 だから変更するときは、最低でもこの2つを意識するよ〜!👀

後方互換(Backward compatibility)🕰️➡️🆕

  • 新しい読む側が、古いイベントも読める状態
  • 例:v1のイベントが残ってても、最新版の処理が止まらない ✅ (イベントの世界では特に大事!) (Confluent Documentation)

前方互換(Forward compatibility)🆕➡️🕰️

  • 古い読む側が、新しいイベントを受け取っても壊れない状態
  • 例:知らないフィールドが増えても「無視して動ける」みたいな感じ ✅ (Confluent)

理想は「両方OK(FULL)」だけど、現実は段階移行で近づけるのが多いよ〜🧸🧩 (Confluent Documentation)


2. バージョンの持ち方 3パターン 🔖🔢🧠

この章のアウトラインにあった3つを、迷わないように整理するね!📦✨

A) version フィールド方式(おすすめ寄り)✅🔖

イベントの形は同じで、メタ情報に version を持つ方式。

  • 例:type: "OrderPlaced" は固定、version: 1 / 2 が増える

  • いいところ 👍

    • type が増えすぎない 🗂️
    • 同じ出来事の進化だと分かりやすい ✨
    • upcast(後で説明)と相性がいい 🔁
  • 気をつけるところ ⚠️

    • 読む側が version を見て分岐・変換する必要あり

B) type にバージョンを含める方式 🏷️🔢

  • 例:type: "OrderPlaced.v1" / type: "OrderPlaced.v2"

  • いいところ 👍

    • ルーティングがラク(文字列で分けられる)📬
    • v1とv2を「別物」として扱いやすい 🧩
  • 気をつけるところ ⚠️

    • type が増えやすい(一覧が膨らみやすい)📚💦
    • 「同じ出来事の進化」なのに、別イベントっぽく見えることも

C) 段階的廃止(古いのも一定期間受ける)🕰️🧹

これは「持ち方」ってより運用のやり方だよ〜!🛠️

  • v2を出しても、いきなりv1を捨てない
  • 一定期間は v1も受けて動かす(または変換して動かす)
  • 廃止タイミングを決めて、ログ/監視しつつ片付ける 🧾🔔

3. どれを選ぶ?🤔🧭(超実用の選び方)

まず迷ったら:A(version フィールド)+ 段階的廃止(C)💞🔖🕰️

理由はシンプル👇

  • 「同じ出来事」を保ちながら進化できる
  • 古いイベントが混ざってても、読む側で吸収しやすい(upcast)
  • 実運用では「いきなり全員同時に更新」ができないことが多いから 🥲🔁

ちなみに「upcast(アップキャスト)」は、古いイベントを読む瞬間に新しい形へ変換する考え方だよ〜! (MartenDB)


4. “壊さない変更”の鉄板ルール 🧱✅✨

イベントの変更で事故りやすいのは、このへん👇😵‍💫

✅ 安全寄り(やりやすい)

  • フィールド追加(しかも optional にする)➕🧸
  • 既存フィールドの意味を変えない(そのままにする)🫶
  • 「新フィールドを使うのは新しい読む側だけ」でもOK

スキーマ運用の世界でも「新規フィールドは optional」「リネームは避ける」が王道だよ〜! (Solace Documentation)

⚠️ 危険寄り(破壊になりがち)

  • フィールド削除 🗑️💥
  • フィールド名のリネーム(実質削除+追加と同じで壊れやすい)🔁💥 (Solace Documentation)
  • 型の変更(number→string など)🔄💥
  • 意味の変更(同じ名前なのに意味が変わるのが一番怖い)🫠

5. バージョン番号の考え方(SemVer と混同しない)🧠🔢

アプリのバージョンに SemVer(MAJOR.MINOR.PATCH) があるけど、イベントの version は「イベントの形(スキーマ)の世代」を表すことが多いよ〜!🔖

SemVerの基本ルール(互換性の増減)はこう👇 (Semantic Versioning)

  • 破壊的変更 → MAJOR
  • 後方互換の機能追加 → MINOR
  • 後方互換のバグ修正 → PATCH

イベントの version にも、発想としては似たものを持ち込める(=壊すときは大きく上げる)けど、**ここでは「イベントスキーマの版」**として扱うと混乱しにくいよ 🧸📌


6. 段階移行のテンプレ(これ覚えると強い)🪜🚀

「v1 → v2」に変えたいときのよくある安全手順だよ〜!✨

ステップ 0:方針決定 🧭📝

  • 何が変わる?(追加?リネーム?意味変更?)
  • 追加で済ませられないか最初に考える(壊さないのが最強)🛡️

ステップ 1:読む側を先に強くする 🛡️📥

  • v2を出す前に、読む側を「v1でもv2でも落ちない」状態へ

  • 方法は2つ

    1. 両対応ハンドラ(versionで分岐)
    2. upcast層で v1→v2 に変換してから処理(おすすめ)🔁 (MartenDB)

ステップ 2:出す側が v2 を出し始める 📤✨

  • version: 2 のイベントを出す
  • v1も混ざり得る期間を許容する(現実)🕰️

ステップ 3:観測して「v1が来なくなった」を確認 👀📈

  • ログ/メトリクスで「v1受信数」を見る
  • 0が続いたら次へGO ✅

ステップ 4:廃止(v1サポート削除)🧹🗑️

  • upcastや分岐を消す
  • 仕様(ドキュメント)も更新する 📝

7. TypeScript実装例:version フィールド方式+upcast 🔖🔁🔷

Upcast(アップキャスト):古いバージョンのイベントを読み込み時に新しい形へ変換する

ここでは「type は固定」「version で世代管理」「内部は v2 だけで処理」を作るよ〜!🧸✨ (※読み込んだ瞬間に v2 へ変換しちゃうのがポイント!)

// 共通フォーマット(Chapter 9でやった形をベースに)🧾✨
export type DomainEvent<TType extends string, TPayload> = {
eventId: string;
occurredAt: string; // ISO文字列
aggregateId: string;
type: TType;
version: number; // ★ここが今回の主役
payload: TPayload;
};

// v1 payload 🧩
export type OrderPlacedPayloadV1 = {
orderId: string;
userId: string; // v2で customerId に変えたくなった想定
items: Array<{ sku: string; qty: number }>;
total: number;
};

// v2 payload 🧩✨(新フィールドも追加した想定)
export type OrderPlacedPayloadV2 = {
orderId: string;
customerId: string;
lines: Array<{ sku: string; quantity: number }>;
total: number;
currency: "JPY" | "USD";
};

// v1 / v2 のイベント型 📨
export type OrderPlacedV1 = DomainEvent<"OrderPlaced", OrderPlacedPayloadV1> & { version: 1 };
export type OrderPlacedV2 = DomainEvent<"OrderPlaced", OrderPlacedPayloadV2> & { version: 2 };

// 受信時点では v1/v2 どっちも来る想定 🤹‍♀️
export type OrderPlacedAny = OrderPlacedV1 | OrderPlacedV2;

// upcast: v1 → v2 に寄せる 🔁✨
export function upcastOrderPlaced(event: OrderPlacedAny): OrderPlacedV2 {
if (event.version === 2) return event;

// v1 → v2 変換(「埋める」「置き換える」「構造変換」)🧩➡️✨
return {
...event,
version: 2,
payload: {
orderId: event.payload.orderId,
customerId: event.payload.userId, // rename吸収
lines: event.payload.items.map((x) => ({ sku: x.sku, quantity: x.qty })), // 構造変換
total: event.payload.total,
currency: "JPY", // デフォルト埋め(ルールで決める)💴
},
};
}

upcast方式のうれしさ 😋💞

  • アプリの中身は v2だけを考えればOK(脳がラク)🧠✨
  • 変換は1か所に集まる(修正も1か所)🧹
  • 「古いイベントが混ざってる」現実に強い 💪🕰️

8. 実装例:ディスパッチ側は “最新版で固定” 📣✅

import { upcastOrderPlaced, OrderPlacedAny, OrderPlacedV2 } from "./events";

type Handler<E> = (event: E) => Promise<void> | void;

// v2だけ受け取るハンドラ ✅
const onOrderPlaced: Handler<OrderPlacedV2> = async (e) => {
// ここは v2 の型しか来ない前提で書ける ✨
console.log("customerId:", e.payload.customerId);
console.log("currency:", e.payload.currency);
};

export async function dispatch(event: { type: string; version: number; payload: unknown } & Record<string, any>) {
// 本当は type で分岐する(ここでは1個だけ例)🧸
if (event.type === "OrderPlaced") {
const v2 = upcastOrderPlaced(event as OrderPlacedAny);
await onOrderPlaced(v2);
}
}

9. 演習:ミニECで「バージョン方針」を決める 🛒📝✨

お題 🎁

OrderPlaced のpayloadを進化させたい!

  • v1:userId, items[].qty
  • v2:customerId, lines[].quantity, currency を追加したい 💴✨

やること ✅

  1. どの方式で持つか選ぶ(A/B)🔖
  2. 「壊さない変更でいけるか?」をまず検討する 🛡️
  3. v1→v2 の **移行手順(ステップ0〜4)**を文章で書く 📝
  4. upcast関数を実装(上の例を参考に)🔁

できたら最高ポイント 🌟

  • currency のデフォルトは?(JPY固定?注文データから推測?)💴🤔

  • リネーム(userId→customerId)は「同じ意味」?それとも意味が違う?🧠

    • もし意味が違うなら、同じイベントの進化じゃなくて別イベントを考えるのもアリ(契約の誠実さ)🤝📜

10. AIメモ:バージョン設計を崩さないための質問テンプレ 🤖🧠✨

  • 「この変更は後方互換を壊す?壊すなら理由は?」🧯
  • 「追加フィールドで済ませる案を3つ」➕
  • 「v1→v2のupcastで、埋めるべきデフォルト値の候補」🧩
  • 「廃止までの段階計画(観測指標込み)をチェックリスト化」📋🔔

11. 章末チェックリスト ✅📋

  • type は「出来事の意味」を保ててる?(意味が変わるなら別イベント検討)🏷️🧠
  • 新フィールドは optional / デフォルトで吸収できる?➕🧸 (Solace Documentation)
  • 読む側は v1/v2 混在でも落ちない?🛡️
  • upcast(変換)が1か所に集約されてる?🔁 (MartenDB)
  • 「v1が来なくなった」を確認する観測がある?👀📈
  • 廃止のタイミングが決まってる?🕰️🧹