Skip to main content

第27章:トランザクション境界と集約の肌感🌰🔒

この章でできるようになるよ🎯✨

  • 「どこまでを1回のトランザクションで守るべき?」が判断できる👀
  • 「集約(Aggregate)って“まとまり”じゃなくて“整合性の範囲”なんだ!」が腑に落ちる🌰
  • 集約ルート経由で更新する設計が、なぜ事故りにくいか説明できる🛡️
  • TypeScriptで“それっぽく”実装できる(ユースケース+リポジトリ+トランザクション)🧩

0️⃣ まず超ざっくり:トランザクション境界って?🔒

トランザクションは「成功ならまとめて確定(commit)」「失敗なら全部なかったことに(rollback)」する“ひとまとまりの操作”だよ💡 ここでいう トランザクション境界 は、もっと設計寄りの言い方で、

“この範囲は1回で必ず整合させる(同時に守る)” を決める線引き✍️

って感じ😊


1️⃣ なんで境界を決めないとヤバいの?😱💥

境界が曖昧だと、こんな事故が起きやすい…👇

  • ✅ 予約は取れたのに、在庫だけ減ってない(逆もある)🌀
  • ✅ A画面では「空きあり」→次の瞬間に「満席」でエラー(同時更新競争)🏃‍♀️🏃‍♀️
  • ✅ 途中で落ちたら“半分だけ反映”して、データがぐちゃぐちゃ🫠

つまり… 「この整合性だけは一瞬たりとも壊れちゃダメ!」 を決めて、そこを 1トランザクションで守る のが大事だよ🛡️✨


2️⃣ 集約(Aggregate)って結局なに?🌰

DDDの「集約」は、ふわっとした“まとまり”じゃなくて、

  • 単一のユニットとして扱えるドメインオブジェクトの塊(例:注文+注文明細) (martinfowler.com)
  • 整合性(consistency)を守る境界(この境界の内側は同時に整合させる) (マイクロソフト ラーン)

っていうのがコアだよ🔥

そして集約にはルールがある👇

✅ 集約ルート(Aggregate Root)

集約の守護者 (Guardian of Aggregate)

  • 集約には ルートが1ついて、外からは基本ルートだけを入り口にする🚪
  • ルートが「集約内の整合性の守護者」になる🛡️
  • 集約内の更新は、ルートのメソッド経由でやるのが基本(子を直でいじらない) (マイクロソフト ラーン)

3️⃣ “集約 = だいたい1トランザクション” の肌感✨

初心者のうちは、まずこの感覚でOK🙆‍♀️

  • **集約の内側で守るべき不変条件(invariant)**は、1回のトランザクションで守る
  • 逆に、集約をまたいで「全部を常に同時に正しく」は、設計コストが爆上がりしがち💣
  • 複数集約にまたがる厳密整合性が必要なら、そもそも境界が変か、要件を見直すサイン🚥

(※のちの章で「イベント」「Outbox」「最終的整合性」みたいな話に繋がっていくよ📣)


4️⃣ 例題で体に入れよう:学内イベント申込み🎪👩‍🎓

🎯 要件(守りたい整合性)

  • イベントは 定員(capacity) がある
  • 申込みは 定員を超えてはいけない(これが不変条件🔥)
  • 申込みが成功したら、参加者リストに追加される

このとき、自然な集約はこう👇

🌰 集約案:Event 集約

  • Event(集約ルート)

    • capacity(定員)
    • attendees(参加者たち)
    • register(userId) というメソッドで「定員チェック+追加」を一気にやる

こうすると、“定員を超えない”がEvent集約の責任になるから、守りやすい🛡️✨


5️⃣ TypeScriptでの実装イメージ🧩(ドメイン→ユースケース→永続化)

5-1. ドメイン:集約ルートに“守らせる”🌰🔒

// domain/event/Event.ts
type EventId = string;
type UserId = string;

class DomainError extends Error {}

export class Event {
private attendees: Set<UserId>;

constructor(
public readonly id: EventId,
private capacity: number,
attendees: UserId[],
// 楽観ロック用(後で説明するよ)
public readonly version: number,
) {
this.attendees = new Set(attendees);
}

get remainingSeats(): number {
return this.capacity - this.attendees.size;
}

hasAttendee(userId: UserId): boolean {
return this.attendees.has(userId);
}

register(userId: UserId): void {
if (this.attendees.has(userId)) {
throw new DomainError("すでに申込み済みだよ🙅‍♀️");
}
if (this.remainingSeats <= 0) {
throw new DomainError("満席だよ🥺");
}
this.attendees.add(userId);
}

// 永続化用にスナップショットを返す(例)
toPrimitives() {
return {
id: this.id,
capacity: this.capacity,
attendees: [...this.attendees],
version: this.version,
};
}
}

ポイントはここ👇

  • 「満席か?」の判断は ユースケースじゃなくてEventが持つ
  • だから、どこから呼ばれても ルールが破られにくい 🛡️

5-2. アプリケーション層:トランザクション境界を作る🎬🔒

ユースケースは「手順の組み立て役」だよ🧑‍🍳✨ ここで “この操作は1トランザクション” を宣言する感覚!

// application/RegisterToEventUseCase.ts
import { Event } from "../domain/event/Event";

export interface EventRepository {
findById(id: string): Promise<Event | null>;
save(event: Event): Promise<void>;
}

export interface TransactionRunner {
runInTransaction<T>(fn: () => Promise<T>): Promise<T>;
}

export class RegisterToEventUseCase {
constructor(
private readonly tx: TransactionRunner,
private readonly events: EventRepository,
) {}

async execute(input: { eventId: string; userId: string }) {
return this.tx.runInTransaction(async () => {
const event = await this.events.findById(input.eventId);
if (!event) throw new Error("イベントがないよ🥺");

event.register(input.userId); // ← ルールは集約が守る🌰

await this.events.save(event); // ← 1トランザクションで確定🔒
return { ok: true };
});
}
}

6️⃣ 実際のDBトランザクションはどう書くの?🗄️🔒

ORM/クエリビルダごとに色々あるけど、たとえば Prisma だと $transaction が基本になるよ💡 (Prisma)

Prismaの“インタラクティブトランザクション”例

// infrastructure/PrismaTransactionRunner.ts
import { PrismaClient } from "@prisma/client";

export class PrismaTransactionRunner {
constructor(private readonly prisma: PrismaClient) {}

async runInTransaction<T>(fn: (tx: PrismaClient) => Promise<T>): Promise<T> {
return this.prisma.$transaction(async (tx) => {
return fn(tx);
});
}
}

✅ 重要:ユースケースは「どうやってDBがトランザクションするか」を知らない方が強い💪 だから TransactionRunner みたいな“口(interface)”を用意するのが気持ちいいよ😊


7️⃣ ありがちな落とし穴😵‍💫(ここ、超大事!)

❌ 落とし穴1:ユースケースが集約ルートを飛ばして子を更新

  • ルールが散らばって、どこかで必ず破れる💥

❌ 落とし穴2:1トランザクションに詰め込みすぎ

  • “ついで更新”が増えて、ロック競合・性能劣化・事故率UP😇

❌ 落とし穴3:他集約をオブジェクト参照で持ちたがる

  • 集約の外は「ID参照」が基本、が鉄板(境界を守りやすい) (集約が整合性境界だよ、って話と繋がる) (マイクロソフト ラーン)

8️⃣ 同時更新(競争)への最低限の備え🏃‍♀️💨

満席チェックはドメインでできても、同時に2人が押したら? 問題があるよね😳

初心者向けの定番はこの2つ👇

✅ A) DB制約+トランザクション

  • 定員を別テーブルで管理して制約をかける、など(DBで守る)🧱

✅ B) 楽観ロック(versionで弾く)

  • Eventに version を持たせて、保存時に「versionが一致しないなら失敗」にする
  • 失敗したら「読み直して再実行」する🔁

このへんは実装がDB/ORMで変わるけど、考え方は同じだよ🧠✨


9️⃣ ミニ演習🧩✍️(手を動かすと一気に分かる!)

演習1:不変条件を1つ、文章で書こう📝

例)

  • 「イベントの参加者数は定員を超えない」
  • 「同じユーザーは同じイベントに2回申し込めない」

演習2:どれをEvent集約に入れる?入れない?🤔

候補)

  • 参加者のプロフィール(学部/学年)
  • 決済情報
  • 出欠情報(当日チェックイン)

👉 コツ:“同時に必ず整合させたいものだけ”を内側に置く🌰🔒

演習3:AIに境界レビューしてもらう🤖👀

  • 「この不変条件を守るには、集約境界はどこが自然?」
  • 「このユースケースのトランザクション境界は適切?詰め込みすぎ?」
  • 「集約をまたいで同時更新してる箇所があったら指摘して」

🔟 まとめ(今日の“腹落ちポイント”)🎉

  • 集約は「まとまり」じゃなくて 整合性の境界 🌰 (マイクロソフト ラーン)
  • 集約ルートは 更新の唯一の入口(整合性の守護者)🛡️ (マイクロソフト ラーン)
  • 「この不変条件は絶対守る!」の範囲を 1トランザクション にする🔒
  • ユースケースは手順担当、ルールは集約担当、永続化はリポジトリ担当🧩

おまけ:TypeScriptの“今”の小ネタ📌✨

2026年1月時点の最新安定版は TypeScript 5.9.2 が公開されているよ(GitHub Releases)。 (GitHub) あと、Nodeの挙動に寄せた --module node20 みたいな安定オプションの話も進んでるので、TSの設定を“ふわっと”にしないのが吉🙆‍♀️ (TypeScript)


次は「第28章:不変条件を守る(壊れない状態しか作らない)🛡️✨」だから、今日の集約の話がそのままド直球で効いてくるよ〜!💖