第18章:最終的整合性の肌感覚(遅れて一致)🌊🕰️
🎯 この章のゴール
- 「最終的整合性(Eventual Consistency)」を “ズレが起きても、最後は一致する設計” として説明できるようになる✨
- ズレがある前提で、ユーザーに不安を与えない表示(UX) を作れるようになる💬💖
- TypeScriptで、“更新は完了したのに、表示は少し遅れる” をミニ実装して体感する🧩⚡
1) 🌱 最終的整合性ってなに?(超ざっくり)
最終的整合性は、ひとことで言うとこう👇
- ✅ 今この瞬間は、データが場所によってズレててもOK
- ✅ でも、新しい更新が止まれば、いずれ全部同じ状態にそろう(収束する)
「すぐ一致」じゃなくて「遅れて一致」なんだね〜!🕰️🌊 この考え方自体が、分散システムでよく使われる“整合性モデル”として知られてるよ📚✨ (ウィキペディア)
2) 🤔 なんでズレるの?(だいたい“非同期”のせい)
ドメインイベントは「起きた事実」をあとから配って、いろんな処理が動くよね📣🚚 このとき、だいたい 非同期 が混ざるからズレが起きるよ〜!
🧠 よくあるズレの流れ(ミニEC)
例:支払い(Payment)→ 発送(Shipping)→ 画面表示(UI)
- 🧾 注文は「支払い済み」になった(書き込みは完了!)
- 📣 そのイベントが配送側に届くのが少し後(ネットワーク/キュー/混雑)
- 📦 配送側の「発送状況」が更新されるのがさらに後
- 🖥️ だから、画面が一瞬「まだ発送準備中」みたいに見える
この “ちょい遅れ” が最終的整合性の正体だよ〜🌊🕰️
3) ⚖️ 強い整合性 vs 最終的整合性(どう選ぶの?)

分散システムは「全部を常に完全一致」にするのが難しいことが多いのね。 そこで どこまでを“即一致”にして、どこからを“遅れて一致”にするか を選ぶ感じ!🧠✨
CAP定理として「整合性(C)・可用性(A)・分断耐性(P)」のトレードオフがよく説明に使われるよ📌 (IBM)
✅ “即一致(強い整合性)”が欲しいもの(例)
- 💳 二重決済しちゃう/残高がマイナスになる → 絶対NG
- 🧾 注文確定の不変条件(Invariants)が壊れる → 絶対NG
→ これは 第17章の「境界(トランザクション範囲)」の中で守る イメージ🔒🧱
✅ “遅れて一致(最終的整合性)”でOKなもの(例)
- 📩 「メール通知が数秒遅れる」
- 📊 「売上集計が数分遅れる」
- 🚚 「配送ステータスが少し後で反映される」
マイクロサービスでは、分散トランザクションを避けて、結果整合性+補償(Compensating) で扱うことが多いよ〜🧯🔁 (martinfowler.com) (補償はSagaの考え方にもつながるよ🤝) (Microsoft Learn)
4) 💬 UXが命!“ズレても安心”にする表示のコツ
最終的整合性でいちばん大事なのはここ😍 ズレがあること自体は普通。でもユーザーが不安になると終わる…!😵💫
✅ コツA:状態を1つ増やす(“処理中”を用意)⏳
いきなり「反映されてない!」に見えるのが怖いので、
Pending(反映待ち)Processing(処理中)Queued(順番待ち)Confirmed(確定)
みたいに、“途中の状態”をちゃんと名前で持つのが強いよ💪✨
✅ コツB:言い切らない(断言は事故る)🫣
ズレがある前提なら、画面文言も「少し待ってね」に寄せると安定するよ🧸💕
- ❌「発送しました!」(まだかも)
- ✅「発送準備を進めています🚚✨」
- ✅「反映まで少し時間がかかることがあります🕰️」
✅ コツC:ユーザーに“次の行動”を渡す🧭
- 🔄 「更新」ボタン
- 🕵️ 「状況を確認する」リンク
- ⏱️ 自動再読み込み(数秒おきに数回だけ)
5) 🧪 体感ミニ実装:更新は終わったのに、表示が遅れる世界
ここからは、“支払い完了 → 配送表示が2秒遅れて更新” を作ってみるよ〜!🌊🕰️✨ (コンソールUIだけで体感できるやつ!)
5.1 📁 フォルダ構成(ミニ)
src/
domain/
DomainEvent.ts
Order.ts
application/
EventBus.ts
OrderService.ts
readmodel/
OrderViewStore.ts
index.ts
5.2 🧾 ドメインイベント型(最小)
// src/domain/DomainEvent.ts
export type DomainEvent<TType extends string, TPayload> = {
eventId: string;
type: TType;
occurredAt: string; // ISO文字列でOK(簡単優先)
aggregateId: string;
payload: TPayload;
};
5.3 🛒 Order集約:支払いでイベントをためる
// src/domain/Order.ts
import { DomainEvent } from "./DomainEvent.js";
export type OrderStatus = "Created" | "Paid";
export class Order {
private domainEvents: DomainEvent<string, any>[] = [];
constructor(
public readonly id: string,
private status: OrderStatus = "Created",
) {}
getStatus() {
return this.status;
}
pay(nowIso: string) {
if (this.status === "Paid") {
throw new Error("すでに支払い済みだよ💳❌");
}
this.status = "Paid";
this.domainEvents.push({
eventId: crypto.randomUUID(),
type: "OrderPaid",
occurredAt: nowIso,
aggregateId: this.id,
payload: { orderId: this.id },
});
}
pullDomainEvents() {
const events = [...this.domainEvents];
this.domainEvents = [];
return events;
}
}
5.4 📣 EventBus:イベントを購読者に配る(今回は超シンプル)
// src/application/EventBus.ts
import { DomainEvent } from "../domain/DomainEvent.js";
type Handler = (event: DomainEvent<string, any>) => Promise<void>;
export class EventBus {
private handlers: Record<string, Handler[]> = {};
on(eventType: string, handler: Handler) {
this.handlers[eventType] ??= [];
this.handlers[eventType].push(handler);
}
async publish(events: DomainEvent<string, any>[]) {
for (const e of events) {
const list = this.handlers[e.type] ?? [];
// “非同期っぽさ”を出すため、handlerはawaitするけど中で遅延してOK
for (const h of list) {
await h(e);
}
}
}
}
5.5 🪟 ReadModel:画面が見る“注文表示”はここ(遅れて更新される)
// src/readmodel/OrderViewStore.ts
export type OrderView = {
orderId: string;
paymentStatus: "Unpaid" | "Paid";
shippingStatus: "NotReady" | "Preparing" | "Ready";
};
export class OrderViewStore {
private store = new Map<string, OrderView>();
get(orderId: string): OrderView {
return (
this.store.get(orderId) ?? {
orderId,
paymentStatus: "Unpaid",
shippingStatus: "NotReady",
}
);
}
upsert(view: OrderView) {
this.store.set(view.orderId, view);
}
}
5.6 🧩 アプリ層:支払いは即完了、配送表示は2秒遅れで更新!
// src/application/OrderService.ts
import { Order } from "../domain/Order.js";
import { EventBus } from "./EventBus.js";
import { OrderViewStore } from "../readmodel/OrderViewStore.js";
export class OrderService {
private orders = new Map<string, Order>();
constructor(
private readonly bus: EventBus,
private readonly views: OrderViewStore,
) {}
createOrder(orderId: string) {
const order = new Order(orderId);
this.orders.set(orderId, order);
// 画面用の初期表示も作っておく
const v = this.views.get(orderId);
this.views.upsert(v);
}
async payOrder(orderId: string) {
const order = this.orders.get(orderId);
if (!order) throw new Error("注文が見つからないよ🧾❌");
// ✅ 書き込み(ドメイン更新)は“今ここで”終わる
order.pay(new Date().toISOString());
// ✅ 画面用ReadModelは “支払いだけ” 先に反映(すぐ見える)
const before = this.views.get(orderId);
this.views.upsert({
...before,
paymentStatus: "Paid",
shippingStatus: "Preparing", // ここがUXの肝!⏳
});
// ✅ イベントは後で配る(配送更新は遅れて起きる想定)
const events = order.pullDomainEvents();
await this.bus.publish(events);
}
}
5.7 🚚 ハンドラ:配送表示の更新は“2秒後”に来る(遅れて一致!)
// src/index.ts
import { EventBus } from "./application/EventBus.js";
import { OrderService } from "./application/OrderService.js";
import { OrderViewStore } from "./readmodel/OrderViewStore.js";
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
function render(view: { paymentStatus: string; shippingStatus: string }) {
if (view.paymentStatus === "Paid" && view.shippingStatus !== "Ready") {
console.log("✅ 支払い完了!ただいま発送準備中だよ…🚚💨(少し待ってね🕰️)");
return;
}
if (view.shippingStatus === "Ready") {
console.log("🎉 発送準備が整ったよ!まもなく発送されるよ📦✨");
return;
}
console.log("🛒 注文を待ってるよ〜");
}
async function main() {
const bus = new EventBus();
const views = new OrderViewStore();
const app = new OrderService(bus, views);
// 配送更新ハンドラ(遅れて一致の主役🌊)
bus.on("OrderPaid", async (e) => {
console.log("📣 OrderPaid を受け取ったよ!(配送側で処理するね)");
await sleep(2000); // ← ここが“遅れ”!
const v = views.get(e.payload.orderId);
views.upsert({ ...v, shippingStatus: "Ready" });
console.log("🚚 配送ステータス更新完了!(ReadModelが追いついた✨)");
});
const orderId = "ORDER-001";
app.createOrder(orderId);
console.log("🖥️ 支払い前の画面:");
render(views.get(orderId));
console.log("\n💳 支払い実行!");
await app.payOrder(orderId);
console.log("\n🖥️ 支払い直後の画面(配送はまだ…):");
render(views.get(orderId));
console.log("\n🕵️ 3秒後にもう一回見るよ〜(追いつくかな?)");
await sleep(3000);
console.log("\n🖥️ 3秒後の画面:");
render(views.get(orderId));
}
await main();
✅ 期待する動き(これが“肌感覚”!)🌊🕰️
- 支払い直後: 「支払い完了!発送準備中…」(不安にさせない⏳💖)
- 数秒後: 「発送準備が整った!」(ReadModelが追いついた✨)
イベント駆動で「別の処理(プロジェクション/ReadModel更新)」が遅れると、一定期間は“結果整合性”になるっていう説明は、イベントソーシングの解説でもよく出てくるよ📚 (Microsoft Learn)
6) 📝 演習(手を動かすと一気に理解できるよ!)
演習1:文言を3つ作ってみよう💬✨
「反映待ち」でも不安にさせない文言を3案作ってみてね🧸 例:
- 「ただいま反映中です⏳」
- 「処理を進めています🚚💨」
- 「数秒で更新されます🕰️」
演習2:自動更新(ポーリング)をつけてみよう🔄🕵️♀️
shippingStatus === "Ready" になるまで、
1秒おきに最大5回だけ views.get() を確認して、画面を更新してみよう!
ヒント:for + sleep(1000) + break でOK👌✨
演習3:“強い整合性が必要なもの”を仕分けしよう⚖️
ミニECで、次を「即一致が必要」「遅れて一致でOK」に分類してみよう📌
- 在庫の確保📦
- メール通知📩
- 売上ランキング📈
- 注文確定🧾
7) 🤖 AI活用(Copilot/Codex向け)プロンプト例✨
7.1 UX文言を整える💬
「反映に数秒かかる」状況でユーザーが不安にならない日本語メッセージを、
短いもの3つ、丁寧なもの3つ、カジュアル3つ作って。
前向きで、責任逃れっぽくならないように。
7.2 “状態設計”を手伝ってもらう🧩
注文の状態遷移(Created → Paid → Shipped…)に、
最終的整合性を前提にした「中間状態」を追加したい。
候補の状態名と、それぞれの意味(いつ入っていつ抜ける)を提案して。
7.3 実装レビュー(不安ポイント検出)🔍
このコードは最終的整合性がある前提です。
ユーザーが「反映されてない」と感じるポイントを洗い出し、
UX表示(文言/ボタン/自動更新)での改善案を出して。
8) ✅ この章のチェックリスト(合格ライン💮)
- 「遅れて一致」を一言で説明できる🌊
- “ズレる期間”に 状態(Processing/Pending) を用意できる⏳
- ユーザー向け表示が 断言しすぎてない(事故りにくい)🫣
- 「即一致が必要な範囲」と「遅れてOK」を仕分けできる⚖️
🧁 おまけ:2026っぽいTypeScript小ネタ(超短く)✨
最近はTypeScriptのコンパイラや言語サービスをネイティブ化して高速化する流れが強くて、Visual Studio側でも TypeScriptのネイティブ版プレビュー が出てたりするよ🚀(大規模コードほど体験が変わりやすい!) (Microsoft Developer) ※この章の内容(最終的整合性)は、TypeScriptのバージョンが変わっても考え方がそのまま使えるよ🧠✨