第26章:楽観ロック(version)で守る🛡️🔢
0. 今日やること(ゴール)🎯✨
「同時更新で“古いデータの上書き事故”が起きる」問題を、**version(版数)**で防ぎます🙂🔒 やりたいことはコレだけ👇
- 注文(Order)に version を持たせる🔢
- 保存するときに 「DBにあるversion」と「自分が持ってるversion」が一致してるかチェック✅
- 一致しなければ **保存を拒否(=競合エラー)**🚫💥
1. まず事故を1つ思い出そ?😱🧨(ロストアップデート)
同じ注文を、2人(または2画面)が同時に編集すると…
- Aさん:商品を1個追加🛒
- Bさん:配送先を変更🏠
…みたいに別々の変更でも、**最後に保存した人が“丸ごと上書き”**してしまうことがあります💥 これが「ロストアップデート(上書き事故)」😵💫
楽観ロックはこれを versionで検知して止めます🛡️✨
2. 楽観ロックってなに?🙂🔢(超やさしく)

2.1 発想は「ノートの版数」📓✨
注文データをノートだと思ってください🙂
- ノートに「version=3」って書いてある📓🔢
- あなたはそれを読んで編集して保存しようとする🖊️
- でもその間に、誰かが先に保存して「version=4」になってたら…?
👉 あなたの編集は“古い版”なので拒否🚫 (= 競合を検知して事故らない🙆♀️)
3. 今回のミニECでの設計方針🧱🛒
3.1 versionはどこに置く?🤔
基本は Aggregate Root(Order)に持たせるのがシンプルです🙂 理由:保存時に比較するのは結局「注文」だし、Orderが“いま自分が持ってる版”を知ってる必要があるからです🔢
- Order.version は **読み取り専用(外から勝手に変えられない)**にする🔒
- 保存成功したら、Repository側で versionを+1して返す(or Orderに反映)✅
4. 実装してみよう(インメモリRepository版)🚀🧪
DBなしでまず動かします!理解優先🙂✨ (DB版は後半に「実務メモ」で出します📌)
4.1 最小のドメイン型(Order + version)🧺🔢
// domain/order.ts
export type OrderId = string;
export type OrderItem = {
sku: string;
qty: number;
};
export class Order {
private _items: OrderItem[];
private _version: number;
private constructor(
public readonly id: OrderId,
items: OrderItem[],
version: number,
) {
this._items = items;
this._version = version;
}
// 新規作成は version=0 からスタート(未保存の状態)
static createNew(id: OrderId): Order {
return new Order(id, [], 0);
}
// Repositoryから復元するとき用
static rehydrate(id: OrderId, items: OrderItem[], version: number): Order {
return new Order(id, items, version);
}
get items(): ReadonlyArray<OrderItem> {
return this._items;
}
get version(): number {
return this._version;
}
addItem(sku: string, qty: number): void {
if (qty <= 0) throw new Error("qty must be positive");
this._items = [...this._items, { sku, qty }];
}
// Repositoryが保存成功したときに呼ぶ(外からは触らせない)
_bumpVersion(): void {
this._version += 1;
}
toSnapshot(): { id: OrderId; items: OrderItem[]; version: number } {
return { id: this.id, items: [...this._items], version: this._version };
}
}
ポイント✅
version=0は「まだ保存してない新規」🍼rehydrateで取り出したOrderは、DB(=Repository)が持ってる版数を持つ🔢
4.2 競合エラー(楽観ロック用)🚨🧱
// application/errors.ts
export class OptimisticLockConflictError extends Error {
constructor(message = "Order was updated by someone else.") {
super(message);
this.name = "OptimisticLockConflictError";
}
}
4.3 Repositoryインターフェース📥📤
// domain/orderRepository.ts
import { Order, OrderId } from "./order";
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>; // ここで version チェックする
}
4.4 インメモリ実装(ここが本丸!)🧠🔥
「保存するときに version を比べる」だけです🙂
// infrastructure/inMemoryOrderRepository.ts
import { Order, OrderId } from "../domain/order";
import { OrderRepository } from "../domain/orderRepository";
import { OptimisticLockConflictError } from "../application/errors";
type StoredOrder = {
id: OrderId;
items: { sku: string; qty: number }[];
version: number;
};
export class InMemoryOrderRepository implements OrderRepository {
private store = new Map<OrderId, StoredOrder>();
async findById(id: OrderId): Promise<Order | null> {
const row = this.store.get(id);
if (!row) return null;
// 取り出した瞬間のスナップショットで Order を復元
return Order.rehydrate(row.id, row.items, row.version);
}
async save(order: Order): Promise<void> {
const snap = order.toSnapshot();
const current = this.store.get(snap.id);
// 新規保存(まだ無い)
if (!current) {
// 新規は version=0 で来る想定
if (snap.version !== 0) {
throw new OptimisticLockConflictError("Unexpected version for new order.");
}
// 保存成功 → versionを1にして格納
this.store.set(snap.id, { ...snap, version: 1 });
order._bumpVersion(); // Order側も追従
return;
}
// 既存更新:versionが一致するかチェック!
if (current.version !== snap.version) {
throw new OptimisticLockConflictError(
`Conflict. expected=${snap.version}, actual=${current.version}`,
);
}
// 一致した → 保存成功、versionを+1
const nextVersion = current.version + 1;
this.store.set(snap.id, { ...snap, version: nextVersion });
order._bumpVersion();
}
}
ここ、超重要✅
- 「比較」→「保存(version+1)」が1つの操作として成り立ってるのがポイントです🔢✨ (DBなら「WHERE version=?」付きUPDATEで“1発”にするのがコツ!後半でやるよ🙂)
5. 事故を再現するテスト🧪😈(そして守る)
「同じ注文を2つ取り出して、先に保存した方だけ通る」ことを確認します✅
// tests/optimisticLock.test.ts
import { describe, it, expect } from "vitest";
import { Order } from "../src/domain/order";
import { InMemoryOrderRepository } from "../src/infrastructure/inMemoryOrderRepository";
import { OptimisticLockConflictError } from "../src/application/errors";
describe("Optimistic Lock (version)", () => {
it("2つの同時更新で、後からの保存が弾かれる", async () => {
const repo = new InMemoryOrderRepository();
// まず注文を作って保存(version: 0 -> 1)
const order = Order.createNew("order-1");
order.addItem("SKU-AAA", 1);
await repo.save(order);
// 同じ注文を2回取り出す(=2画面を想定)
const a = await repo.findById("order-1");
const b = await repo.findById("order-1");
if (!a || !b) throw new Error("order not found");
// それぞれ別の変更
a.addItem("SKU-BBB", 1);
b.addItem("SKU-CCC", 1);
// Aが先に保存(OK)
await repo.save(a);
// Bが保存しようとすると(古いversionのまま)→ 競合で弾かれる
await expect(repo.save(b)).rejects.toBeInstanceOf(OptimisticLockConflictError);
});
});
テストが通ったら🎉
- 「上書き事故」は起きない🙆♀️
- 代わりに「競合」が検知できる🔔✨
6. 競合が起きたらどうする?😵💫➡️🙂(基本の3択)
楽観ロックは「拒否」まではしてくれるけど、その後の対応を決める必要があります📌
6.1 いちばん多い:ユーザーに“更新してね”🔄🙂
- 画面に「他の人が先に更新しました。最新を読み直してもう一度お願いします」みたいに出す🪟✨
- 管理画面・業務系でよくあるやつ!
6.2 自動リトライ(安全な操作だけ)🔁🛡️
- 例:「数量+1」みたいに機械的に再適用できる操作
- ただし、支払い確定みたいな操作は危険⚠️(冪等性の章でやるよ🔂)
6.3 マージ画面(差分を見せて選ばせる)🧩👀
- Wikiやフォームの編集などで見かけるやつ!
- 実装コストは上がるけど親切🙂💕
7. 実務メモ:DBでの実装はこうする🗄️🔢(超大事)
7.1 “1発UPDATE”が基本(WHERE version)⚡
DBで正しくやるコツはコレ👇 UPDATEをするときに「id AND version」で絞る✅ 更新できた行数が0なら、競合です🚫
(例:SQLイメージ)
UPDATE orders
SET items_json = ?, version = version + 1
WHERE id = ? AND version = ?;
- これができると、比較と更新が“同時に成立”するので強い💪✨
- ORMでも最終的にはこの発想に寄せるのが安全です🙂
7.2 Prismaでの考え方(versionフィールド推奨)🧠✨
Prismaは「versionフィールドを使った楽観的並行制御(OCC)」を推奨しています📌 (Prisma) また、古いバージョンでは更新時に非ユニーク条件での絞り込み制約があった話もあります(今使ってるバージョンの仕様は要確認)📝 (Prisma)
7.3 TypeORMのロック指定(参考)🔐
TypeORMには楽観ロックの指定例がドキュメントにあります(取得時にversionを指定してロックモードを使う形)📚 (typeorm.io)
8. 2026年の周辺事情ちょいメモ(教材の鮮度)🆕✨
- Node.js は「v24 が Active LTS」「v25 が Current」として公開されています📌(本番は基本LTSが安心🙂) (Node.js)
- TypeScript は 5.9 系のリリースノートが継続更新されていて、次の大きな流れ(6/7)について公式ブログでも進捗が出ています📝 (TypeScript)
9. まとめ(今日のチェック✅)🧾✨
- 楽観ロックは versionで“古い更新”を拒否する仕組み🙂🔢
- 実装の心臓は「保存時に version を比較」✅
- DBでは WHERE id AND version の1発UPDATEが強い💪⚡
- 競合が起きたら「更新してね🔄」「安全な操作だけリトライ🔁」「マージ🧩」のどれかで扱う🙂
10. ミニ課題(手を動かそう)🖊️🎮
- テストを1本追加🧪✨
- 「Aが保存したあと、Bが保存失敗したら、Bは最新を取り直す」までを書いてみよう🔄🙂
- 競合メッセージを改善💬💕
expectedとactualを含めてログに出す(運用で超助かる🔍)
- ちょいクイズ🎯
- 「versionを比較する場所」を Repository じゃなくて ApplicationService に置いたら、何が起きそう?🤔💥(ヒント:抜け道ができる🚪😈)