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

第29章 最終的整合性の肌感覚(“ズレ”と友達になる)🕒🙂✨

この章では、「CQRSでよく出る “反映の遅れ”」を 怖がらずに扱える ようになるのがゴールだよ〜!🫶 (結論:ズレは バグ じゃなくて、設計上のトレードオフ で、ちゃんと“手当て”できるよ😌)


1) まず起きること:注文したのに一覧に出ない😵‍💫🍙

学食アプリで…

  1. 「注文する」ボタン押した!✅
  2. 「注文できました!」って返ってきた!🎉
  3. なのに…「注文一覧」に まだ出てこない 😭

これ、CQRSで Write(更新)と Read(参照)が別 になると、かなり起きがちなんだ〜。


2) それ、バグじゃなく “最終的整合性” 🧠✨

最終的整合性(Eventual Consistency) っていうのは、

  • 更新した直後の一瞬、読む側(Readモデル)が古いことがある
  • でも少し待つと、最終的には新しい状態に追いつく

…っていう “遅れて合流する” 仕組みだよ🕒🙂

実際、AWSのDynamoDBでも「最近の書き込みがすぐ反映されないことがある、少し後に追いつく」って説明されてるよ(これがまさに最終的整合性)📚✨ (AWS ドキュメント) 分散システムでは「全部を常に同期で揃える」のが難しくて、最終的整合性を 前提に設計する必要がある って有名な解説もあるよ📌 (martinfowler.com)


3) CQRSで “ズレ” が生まれる場所(図で一発)🪞🔧

ポイントはここ👇

  • Command(更新) は Write側に入る
  • Query(参照) は Read側を見る
  • Read側は、イベント(または更新結果)を受け取って 投影(Projection) で追いつく

ざっくり図にするとこう👇

[Client]
| POST /orders (Command)
v
[Write Model] --保存--> [Write DB]
|
| Event: OrderPlaced
v
[Event / Queue]
|
| (Projectionが非同期で処理) ⏳
v
[Read Model] --保存--> [Read DB]
^
| GET /orders (Query)
[Client]

この 「⏳の部分」 があると、ズレが自然に起きるよ〜🙂


4) “ズレOK” と “ズレNG” を決めるコツ🎯🧠

ズレが怖いのは、「どこでもズレる」と思うから! 現実は ズレていい場所とダメな場所がある のだ〜✨

✅ ズレても大抵OK(Readが遅れても許される)

  • 売上集計📊(ちょい遅れても困らない)
  • 人気メニューTOP3🏆(数秒遅れでも平和)
  • 注文一覧📋(“反映中” 表示があればOK)

❌ ズレると困る(強めに揃える or 手当て必須)

  • 決済完了の可否💳(ここでズレると炎上しがち🔥)
  • 在庫の最終1個🍙(二重販売は事故)
  • 「注文できた/できてない」判断そのもの

この判断、マジ大事!✨(設計のセンスが育つやつ🌱)


5) ズレと友達になる “UX手当て” 6選🫶🔄✨

cqrs_ts_study_029_ux_for_lag.png

ズレをゼロにしようとすると、システムが重く&複雑になりがち。 だから ユーザー体験で吸収する のが王道だよ😊

① 「反映中…」をちゃんと見せる🕒🫧

  • 注文直後に一覧に無くても「反映中です」って出すだけで安心感MAX

② 操作ID(correlationId / operationId)を返す🧾✨

  • Commandの返り値で operationId を返して
  • フロントは /operations/{id} を見に行く(あとで反映されたか確認)

③ ポーリング(再取得)+指数バックオフ🔁📉

  • 0.5秒 → 1秒 → 2秒…みたいに間隔を伸ばして優しく待つ

④ Pushで更新(SSE / WebSocket)📡✨

  • 反映した瞬間にサーバーから「きたよー!」って通知
  • 体感が一気に良くなる(後の章でやると超楽しいやつ🥳)

⑤ Readモデルに “反映時刻 / version” を入れる🕰️🔖

  • 「この一覧、何秒前の情報か」を見える化できる

⑥ 「更新した直後だけはWriteを読む」もアリ✅

  • “支払い完了画面だけ” はWrite側の状態を見て確定させる…とかね (全部をそうするとCQRSのうまみが減るので、ピンポイントで👍)

6) ミニ実装:非同期投影で “反映中” を体験しよう🍙🧩

ここから、わざと遅延を入れて「ズレ」を再現するよ😆 (“肌感覚” は体験がいちばん!)

6-1) まず型:イベントと操作ステータス📦✨

// src/shared/types.ts
export type EventId = string;
export type OperationId = string;

export type DomainEvent =
| {
id: EventId;
type: "OrderPlaced";
occurredAt: string; // ISO
operationId: OperationId;
payload: {
orderId: string;
customerName: string;
totalYen: number;
};
};

export type OperationStatus =
| { operationId: OperationId; state: "PENDING"; createdAt: string }
| { operationId: OperationId; state: "DONE"; doneAt: string }
| { operationId: OperationId; state: "FAILED"; failedAt: string; reason: string };

6-2) Write側:注文を保存してイベントを積む(Command)🧾✅

// src/write/placeOrderHandler.ts
import { randomUUID } from "node:crypto";
import type { DomainEvent, OperationStatus } from "../shared/types.js";

type WriteOrder = {
orderId: string;
customerName: string;
totalYen: number;
status: "ORDERED";
};

export class WriteOrderRepository {
private map = new Map<string, WriteOrder>();
save(order: WriteOrder) {
this.map.set(order.orderId, order);
}
find(orderId: string) {
return this.map.get(orderId);
}
}

export class EventQueue {
private q: DomainEvent[] = [];
push(e: DomainEvent) {
this.q.push(e);
}
pop(): DomainEvent | undefined {
return this.q.shift();
}
}

export class OperationStore {
private map = new Map<string, OperationStatus>();
put(status: OperationStatus) {
this.map.set(status.operationId, status);
}
get(operationId: string) {
return this.map.get(operationId);
}
}

export class PlaceOrderHandler {
constructor(
private readonly writeRepo: WriteOrderRepository,
private readonly queue: EventQueue,
private readonly opStore: OperationStore,
) {}

execute(input: { customerName: string; totalYen: number }) {
// 超ミニバリデーション(第11章の考え方で増やしてね🙂)
if (!input.customerName) throw new Error("customerName required");
if (input.totalYen <= 0) throw new Error("totalYen must be > 0");

const orderId = randomUUID();
const operationId = randomUUID();

this.writeRepo.save({
orderId,
customerName: input.customerName,
totalYen: input.totalYen,
status: "ORDERED",
});

this.opStore.put({
operationId,
state: "PENDING",
createdAt: new Date().toISOString(),
});

this.queue.push({
id: randomUUID(),
type: "OrderPlaced",
occurredAt: new Date().toISOString(),
operationId,
payload: { orderId, customerName: input.customerName, totalYen: input.totalYen },
});

// ここで「成功」を返しても、Readはまだ追いついてないかも🙂
return { orderId, operationId };
}
}

6-3) Read側:投影ワーカー(わざと遅らせる)⏳🌱

// src/read/projectionWorker.ts
import type { DomainEvent, OperationStore } from "../shared/types.js";
import { EventQueue } from "../write/placeOrderHandler.js";

type ReadOrderRow = {
orderId: string;
customerName: string;
totalYen: number;
projectedAt: string;
};

export class ReadOrderRepository {
private rows: ReadOrderRow[] = [];
upsert(row: ReadOrderRow) {
const i = this.rows.findIndex((x) => x.orderId === row.orderId);
if (i >= 0) this.rows[i] = row;
else this.rows.unshift(row);
}
list() {
return this.rows;
}
}

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export class ProjectionWorker {
constructor(
private readonly queue: EventQueue,
private readonly readRepo: ReadOrderRepository,
private readonly opStore: OperationStore,
) {}

async start() {
// ずっと回す簡易版(本番は停止制御など必要だよ🧯)
// eslint-disable-next-line no-constant-condition
while (true) {
const ev = this.queue.pop();
if (!ev) {
await sleep(100);
continue;
}

// ✨ここが「ズレ」を作る本体✨
await sleep(1500); // わざと1.5秒遅らせる😆

try {
if (ev.type === "OrderPlaced") {
this.readRepo.upsert({
orderId: ev.payload.orderId,
customerName: ev.payload.customerName,
totalYen: ev.payload.totalYen,
projectedAt: new Date().toISOString(),
});

this.opStore.put({
operationId: ev.operationId,
state: "DONE",
doneAt: new Date().toISOString(),
});
}
} catch (e) {
this.opStore.put({
operationId: ev.operationId,
state: "FAILED",
failedAt: new Date().toISOString(),
reason: e instanceof Error ? e.message : "unknown",
});
}
}
}
}

6-4) Query:一覧と操作ステータスを見る👀📋

// src/read/queryService.ts
import { ReadOrderRepository } from "./projectionWorker.js";
import { OperationStore } from "../write/placeOrderHandler.js";

export class QueryService {
constructor(
private readonly readRepo: ReadOrderRepository,
private readonly opStore: OperationStore,
) {}

getOrderList() {
return this.readRepo.list();
}

getOperation(operationId: string) {
return this.opStore.get(operationId) ?? null;
}
}

6-5) “クライアント”で体験:反映されるまで待つ🔄🙂

フロントがまだ無くても、まずは fetchで疑似体験 できるよ〜!

// src/demo/clientDemo.ts
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function demo(baseUrl: string) {
const res = await fetch(`${baseUrl}/orders`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ customerName: "こみやんま", totalYen: 850 }),
});

const { orderId, operationId } = (await res.json()) as {
orderId: string;
operationId: string;
};

console.log("注文受付!🎉", { orderId, operationId });
console.log("でも一覧はまだかも…🕒");

for (let i = 0; i < 10; i++) {
const op = await fetch(`${baseUrl}/operations/${operationId}`).then((r) => r.json());
console.log("operation:", op);

if (op?.state === "DONE") {
const list = await fetch(`${baseUrl}/orders`).then((r) => r.json());
console.log("一覧に出た!📋✨", list[0]);
return;
}

await sleep(300 + i * 200); // 少しずつ待つ(やさしめ)🙂
}

console.log("まだ反映されない…🥲(ログ見よ!)");
}

この体験をすると、「あ、成功返した直後にReadが古いって普通に起きるんだ」って腑に落ちるよ😌✨


7) よくある事故ポイント(ここだけは注意!)⚠️🧯

❌ 事故①:ユーザーが連打して二重注文🍙🍙😱

  • 「反映されない=失敗」と思って押し直す → だから “反映中” 表示 めっちゃ大事!

❌ 事故②:Readを正として判定しちゃう

  • 「一覧にないから未注文」と判断してしまう → “確定判定” は Write側 or OperationStatus を使うのが安全🙂

❌ 事故③:待ちすぎてUIが固まる

  • “反映されるまで待つ” をやりすぎると体験が悪い → ほどほどにして「あとで追いつく表示」に寄せるのがコツ!

8) ミニ演習(紙でもOK📝✨)

演習A:どこがズレてOK?どこがNG?🎯

次を「OK / NG / 条件付き」で分けてみてね🙂

  • 注文一覧の表示
  • 売上集計
  • 支払い完了表示
  • 在庫残数(ラスト1個)
  • “注文できたかどうか” の最終判定

演習B:UXの文言を作る💬🫶

「反映中」をどう言うと不安が消える? 例:「注文は受け付けました!一覧に反映するまで少し待ってね🙂」


9) AI活用コーナー🤖✨(この章と相性バツグン)

① “ズレOK/NG” の判断を一緒にやってもらう

  • 「学食注文アプリで、ズレが許容できる画面とできない画面を列挙して、理由も書いて」

② 反映中の文言を10案出してもらう🫧

  • 「不安を減らす、短くて優しい文言を10個。絵文字も混ぜて」

③ operationId設計レビュー🧠

  • 「operationId方式のAPI設計で、危険な点・改善案を指摘して」

10) まとめ🎉

  • CQRSでは Readが遅れて追いつくことがある(最終的整合性)🕒
  • ズレは “悪” じゃなくて、どこで許すかを設計するもの 🧠
  • UX(反映中、operationId、再取得、push)で不安を消せる🫶
  • 体験して慣れるのが最短ルート😆✨

ちなみに、分散システムの世界では最終的整合性は普通に使われていて、AWSでも「結果がすぐ見えないかも」と明示されてるよ📚 (AWS ドキュメント) そして分散では整合性の扱いが難しいから、最終的整合性をちゃんと管理しようね、という有名な指摘もあるよ📌 (martinfowler.com)


おまけ:2026年1月24日時点の “開発環境まわり” 小ネタ🧁

  • TypeScriptは 5.9がリリース済み で、チームは 6.0と7.0を早期2026に向けて進めている と説明してるよ 🧠✨ (Microsoft for Developers)
  • Node.jsは v24がActive LTS、v25がCurrentとして更新されてるよ(セキュリティリリースも出てる)🔐 (Node.js)

次の第30章(冪等性🔁🛡️)は、この章の「連打・再送・二重処理」を 安全にする本命 だから、ここまで来たら超いい流れだよ〜!🥳