DDDの集約(Aggregate)とは?集約ルート・不変条件・トランザクション境界の設計ルールを徹底解説

DDDの集約(Aggregate)とは何かを一次資料に基づき徹底解説する。集約ルートの役割、不変条件がなぜ境界を決めるのか、Vernonの集約設計4原則、ID参照と結果整合性、そしてTypeScriptとzodによる実装パターンまでを体系的にまとめた決定版記事。

DDD(ドメイン駆動設計)を学ぶと必ず出会うのが「集約(Aggregate)」という概念である。だが「集約ルートって結局何?」「境界はどう引けばいいのか?」「トランザクションと何が違うのか?」という疑問を持ったまま設計を進めてしまう人は多い。

本記事では、Eric Evansの原著『Domain-Driven Design』、Vaughn Vernonの論文シリーズ"Effective Aggregate Design"、Martin Fowlerのbliki記事、そして『実践ドメイン駆動設計』といった一次資料に基づき、集約の定義から集約ルート、不変条件、トランザクション境界、結果整合性、そしてTypeScriptでの実装パターンまでを一気通貫で解説する。

集約(Aggregate)とは何か

DDDの提唱者であるEric Evansは、集約を「データの変更を行う際の単位として扱う、関連するオブジェクトのまとまり」として定義した( Evans, Domain-Driven Design, 2003 ; Martin Fowler, DDD_Aggregate )。

なぜこのような「まとまり」が必要なのか。理由はシンプルで、ドメインオブジェクトを1つずつ独立に変更できるようにしてしまうと、複数のオブジェクトにまたがるビジネスルールを誰も守れなくなるからだ。

たとえばECサイトの注文を考えてみよう。注文(Order)には複数の注文明細(OrderLine)がぶら下がっている。もしOrderLineを単独で自由に生成・削除できるAPIを公開してしまうと、「注文の合計金額は明細の合計と一致する」というルールをアプリケーションのどこか別の場所で毎回チェックしなければならなくなる。チェック漏れが起きれば、整合性の取れていない注文データがそのままデータベースに書き込まれてしまう。

集約は、このような「常に守られるべきルール」を1つの単位に閉じ込め、その単位の外からは内部を勝手にいじれないようにすることで、ドメインモデル自身にルールを守らせる仕組みである。単なる「関連オブジェクトのグルーピング」ではなく、整合性を保証する責任の単位だと捉えるのが正確だ。

集約ルート(Aggregate Root)と境界

集約を構成するオブジェクトのうち、外部から参照してよいのはただ1つ、**集約ルート(Aggregate Root)**と呼ばれるエンティティだけである。集約ルート以外の内部オブジェクト(内部エンティティや値オブジェクト)は、集約ルートを経由しない限り外部から直接参照・変更してはならない( Martin Fowler, DDD_Aggregate )。

先ほどの例で言えば、Orderが集約ルートであり、OrderLineOrderの内部オブジェクトである。アプリケーション層やリポジトリはOrderを経由してのみOrderLineを追加・変更でき、OrderLineを単独で取得・更新するAPIは存在しない。

この制約には2つの意味がある。

  1. 参照の制約: 集約の外側にあるオブジェクトは、集約ルートへの参照だけを保持できる。内部オブジェクトへの参照を外部に持ち出させないことで、「誰が」「いつ」内部状態を書き換えたのかを集約ルートが完全に把握できる状態を保つ。
  2. 永続化の単位: リポジトリは集約単位でロード・保存を行う。集約全体を1つのまとまりとして読み込み・保存することが、集約設計の基本原則の1つである( Martin Fowler, DDD_Aggregate )。OrderLineだけを個別にデータベースへ保存するリポジトリメソッドは用意しない。

図にすると次のような構造になる。

Order集約の構造 図1: Order集約では、外部からの操作はすべて集約ルートOrderを経由する。内部のOrderLineへの直接アクセスは許されず、別集約であるCustomerへはオブジェクト参照ではなくID(customerId)で参照する。

他の集約(この例ではCustomer)への関連は、オブジェクト参照ではなくcustomerIdのようなID参照にとどめる。これも集約ルートを唯一のゲートウェイとする設計の帰結であり、次章で述べる不変条件の話とも直結している。

不変条件(invariant)こそが集約境界を決める

ここが本記事で最も強調したいポイントである。集約の境界は、トランザクションの都合で決めるものではない。常に守られるべきビジネスルール(不変条件、invariant)によって決まる。

不変条件とは、オブジェクトの状態がどのように変化しても、その変化が完了した瞬間には必ず成り立っていなければならない性質のことだ。単なる入力バリデーション(「文字列が空でないこと」など単一フィールドの形式チェック)とは異なり、不変条件は複数のオブジェクトにまたがる関係性のルールであることが多い。「注文の合計金額は明細行の合計と一致する」「確定済みの注文には明細行が1件以上存在する」といったルールがその例だ。

Vaughn Vernonは論文"Effective Aggregate Design"の中で、この考え方を**真の不変条件(true invariants)**という言葉で表現している。あるルールを同じ集約に含めるべきかどうかを判断する基準は、「そのルールが、トランザクションが完了した直後の時点で即座に(atomically)成り立っていなければならないかどうか」である( Vaughn Vernon, Effective Aggregate Design Part I: Modeling a Single Aggregate )。即座の整合性が必要なルールだけを集約の内側に閉じ込め、それ以外は集約の外に置いて結果整合性(後述)に委ねる。これがVernonの一貫した主張である。

つまり設計者が最初に決めるべきなのは「このオブジェクト群を1トランザクションでまとめて保存したいから1つの集約にしよう」という実装都合ではなく、「このルールは常に成り立っていなければならない真の不変条件か?」という問いだ。トランザクション境界は、こうして決まった集約境界の結果として後からついてくるものであり、逆ではない。

この判断フローを図にすると以下のようになる。

不変条件が境界を決める決定フロー 図3: モデリング対象のルールが「常に・即座に成り立たなければならない真の不変条件」であれば同じ集約に含め、そうでなければ別の集約としてID参照+結果整合性で連携させる。

具体例で考えてみよう。「注文の合計金額は明細行の合計と一致する」は、注文が確定した瞬間に必ず成り立っていなければならない真の不変条件だから、OrderOrderLineは同じ集約に属する。一方、「顧客の氏名や住所が変わったら、過去の注文にもすぐに反映されるべきか」は、多くのドメインでは真の不変条件ではない。注文時点の顧客情報のスナップショットを持っていれば十分であり、CustomerOrderとは別の集約でよい。

Vernonの集約設計4原則

Vernonは"Effective Aggregate Design"の中で、集約を小さく、疎結合に保つための4つの経験則を提示している( InfoQ, Designing and Storing Aggregates in Domain-Driven Design ; dddcommunity.org, Vernon 2011 )。

  1. 真の不変条件をコンシステンシー境界の中で守る: 1つのトランザクションで更新してよい集約は1つだけに限定する。
  2. 集約は小さく設計する: 最小の集約は、ルートとなるエンティティ1つだけで構成される。まずは「すべてのエンティティは自分自身だけの集約である」という前提から出発し、真の不変条件を守るために本当に必要な要素だけを追加していく。
  3. 他の集約へはIDでのみ参照する: ある集約から別の集約のオブジェクトへ直接参照を持たせない。常に識別子(ID)を介して参照する。
  4. 境界の外側は結果整合性を用いる: 集約境界の外側との整合性は、同期的なトランザクションではなく、ドメインイベントなどを使った結果整合性(eventual consistency)で実現する。

この4原則は、日本語訳書『実践ドメイン駆動設計』第10章「集約」でも一貫して紹介されており、「小さく設計する」「値オブジェクトを中心に構成する」「真の不変条件を境界の中でモデリングする」「他の集約へは識別子で参照する」という指針として説明されている( codezine.jp, 実践DDD本 第10章「集約」 )。

原則2の「小さく設計する」は、単なるパフォーマンス上のTipsではない。集約が大きくなるほど、同時に更新しようとする操作同士が衝突しやすくなり(楽観的ロックの競合)、ロードするデータ量も増え、テストも複雑になる。真の不変条件を守るために本当に必要な最小限の範囲まで集約を絞り込むことが、スケーラビリティとモデルの単純さの両方に効いてくる。

TypeScriptでの実装パターン

ここまでの原則を、ECサイトの注文(Order/OrderLine)を例にTypeScriptで実装してみる。以前の記事では銀行口座の残高チェックをzodrefineだけで表現していたが、ここではその考え方を土台にしつつ、集約ルートのクラス実装・ファクトリ・不変条件チェック・リポジトリまでを一貫させた、より実務に近い例に発展させる。

まず、値オブジェクトと内部エンティティOrderLineを定義する。OrderLineは集約の内部オブジェクトなので、外部から直接newさせず、Order経由でのみ生成・変更させる。

import { z } from "zod";

// ---- 値オブジェクト ----
const MoneySchema = z.number().int().nonnegative(); // 最小通貨単位(円)で管理
type Money = z.infer<typeof MoneySchema>;

const ProductIdSchema = z.string().uuid();
type ProductId = z.infer<typeof ProductIdSchema>;

const CustomerIdSchema = z.string().uuid();
type CustomerId = z.infer<typeof CustomerIdSchema>;

// OrderLineは集約内部のエンティティ。外部から直接生成させない。
const OrderLineSchema = z.object({
  productId: ProductIdSchema,
  unitPrice: MoneySchema,
  quantity: z.number().int().positive(),
});
type OrderLineProps = z.infer<typeof OrderLineSchema>;

class OrderLine {
  private constructor(private readonly props: OrderLineProps) {}

  static create(props: OrderLineProps): OrderLine {
    OrderLineSchema.parse(props); // 単一明細としてのバリデーション
    return new OrderLine(props);
  }

  get productId() {
    return this.props.productId;
  }
  get quantity() {
    return this.props.quantity;
  }
  get subtotal(): Money {
    return this.props.unitPrice * this.props.quantity;
  }
  get snapshot(): Readonly<OrderLineProps> {
    return this.props;
  }

  withQuantity(quantity: number): OrderLine {
    return OrderLine.create({ ...this.props, quantity });
  }
}

次に集約ルートOrderを実装する。ポイントは3つある。

  • 合計金額を専用フィールドに持たない: totalは常にlinesから計算するプロパティにする。こうすることで「合計は明細の合計と一致する」という不変条件を、チェックするまでもなく構造的に保証できる。
  • 状態変化の後には必ずassertInvariants()を呼ぶ: 明細の追加や確定操作など、状態を変えるすべてのメソッドの末尾で不変条件をまとめて検証する。
  • 他集約へはIDだけを持つ: customerIdはただの文字列(ID)であり、Customerオブジェクトへの参照は持たない。
type OrderStatus = "Draft" | "Placed";

// 集約全体としての不変条件をzodのrefineでまとめて表現する
const OrderInvariants = z
  .object({
    status: z.enum(["Draft", "Placed"]),
    lineCount: z.number().int().nonnegative(),
    total: MoneySchema,
  })
  .refine((s) => s.status !== "Placed" || s.lineCount > 0, {
    message: "確定済みの注文には明細行が1件以上必要である",
  })
  .refine((s) => s.status !== "Placed" || s.total > 0, {
    message: "確定済みの注文の合計金額は0円より大きい必要がある",
  });

class DomainError extends Error {}

class Order {
  private lines: OrderLine[] = [];
  private status: OrderStatus = "Draft";
  private domainEvents: Array<{ type: string; [k: string]: unknown }> = [];

  private constructor(
    public readonly id: string,
    public readonly customerId: CustomerId, // Customer集約へはIDのみで参照する
  ) {}

  // ---- ファクトリ: 新規作成 ----
  static create(id: string, customerId: string): Order {
    CustomerIdSchema.parse(customerId);
    return new Order(id, customerId);
  }

  // ---- ファクトリ: 永続化層からの復元 ----
  static reconstruct(
    id: string,
    customerId: string,
    lines: OrderLineProps[],
    status: OrderStatus,
  ): Order {
    const order = new Order(id, customerId);
    order.lines = lines.map(OrderLine.create);
    order.status = status;
    return order;
  }

  get total(): Money {
    return this.lines.reduce((sum, l) => sum + l.subtotal, 0);
  }

  addLine(productId: string, unitPrice: number, quantity: number): void {
    if (this.status !== "Draft") {
      throw new DomainError("確定済みの注文には明細を追加できない");
    }
    const existing = this.lines.find((l) => l.productId === productId);
    this.lines = existing
      ? this.lines.map((l) =>
          l.productId === productId ? l.withQuantity(l.quantity + quantity) : l,
        )
      : [...this.lines, OrderLine.create({ productId, unitPrice, quantity })];

    this.assertInvariants();
  }

  place(): void {
    if (this.status !== "Draft") {
      throw new DomainError("下書き状態の注文のみ確定できる");
    }
    this.status = "Placed";
    this.assertInvariants();
    this.domainEvents.push({
      type: "OrderPlaced",
      orderId: this.id,
      total: this.total,
    });
  }

  pullDomainEvents() {
    const events = this.domainEvents;
    this.domainEvents = [];
    return events;
  }

  toSnapshot() {
    return {
      id: this.id,
      customerId: this.customerId,
      status: this.status,
      lines: this.lines.map((l) => l.snapshot),
    };
  }

  private assertInvariants(): void {
    OrderInvariants.parse({
      status: this.status,
      lineCount: this.lines.length,
      total: this.total,
    });
  }
}

最後に、集約単位で永続化を行うリポジトリを実装する。リポジトリが公開するのはsavefindByIdだけであり、OrderLineだけを個別に読み書きするメソッドは存在しない。

interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | undefined>;
}

class InMemoryOrderRepository implements OrderRepository {
  private store = new Map<string, ReturnType<Order["toSnapshot"]>>();

  async save(order: Order): Promise<void> {
    // 集約は必ず丸ごと1単位で保存する。
    this.store.set(order.id, order.toSnapshot());
  }

  async findById(id: string): Promise<Order | undefined> {
    const snapshot = this.store.get(id);
    if (!snapshot) return undefined;
    return Order.reconstruct(
      snapshot.id,
      snapshot.customerId,
      snapshot.lines as OrderLineProps[],
      snapshot.status as OrderStatus,
    );
  }
}

使用例は次の通りだ。不変条件を破ろうとする操作は例外として弾かれる。

const repo = new InMemoryOrderRepository();
const customerId = crypto.randomUUID();

const order = Order.create(crypto.randomUUID(), customerId);
order.addLine(crypto.randomUUID(), 1200, 2);
order.addLine(crypto.randomUUID(), 3000, 1);
order.place(); // ここでOrderPlacedイベントが発行される

await repo.save(order);

const reloaded = await repo.findById(order.id);
console.log(reloaded?.total); // 5400

// 不変条件違反の例: 明細が空のまま確定しようとする
const empty = Order.create(crypto.randomUUID(), customerId);
try {
  empty.place();
} catch (e) {
  console.error((e as Error).message); // "確定済みの注文には明細行が1件以上必要である"
}

zodrefineは、集約全体のスナップショットに対する横断的なルールをまとめて宣言的に書けるため、状態変化のたびに実行する不変条件チェックの実装として相性が良い。一方で、合計金額のように構造的に保証できる不変条件は、そもそもフィールドとして重複して持たせないほうが堅牢である。この2つを組み合わせるのが実務的なバランスだ。

よくある設計の失敗

集約設計でつまずきやすいパターンを4つ挙げる。

巨大集約(神集約)

「関連しそうなものは全部同じ集約に入れておけば安全」という発想で集約を肥大化させてしまうケース。Vernonはこれを戒め、集約が大きくなるほどロード・保存のコストが増え、同時更新の衝突が頻発し、テストも困難になると指摘している( Vaughn Vernon, Effective Aggregate Design Part I )。「関連している」ことと「真の不変条件で結びついている」ことは別物であり、後者だけが集約を1つにまとめる根拠になる。

集約間の直接参照

ある集約のエンティティが、別の集約のオブジェクトへの参照(ポインタ)を直接保持してしまうケース。前述のように、他集約へは必ずIDで参照すべきである。オブジェクト参照を許すと、ORMの遅延ロードによって意図せず巨大なオブジェクトグラフ全体が読み込まれたり、集約の境界を越えた暗黙の依存が生まれたりする。

1トランザクションで複数集約を更新する

「ついでだから」と1つのトランザクションの中で複数の集約を同時に更新してしまうケース。Vernonの原則1に反しており、次章で詳しく述べるように、集約間の整合性はドメインイベントによる結果整合性で解決すべき問題である。

貧血ドメインモデル

集約ルートのクラスがgetter/setterだけの入れ物になっており、業務ロジックや不変条件のチェックがすべてサービス層に書かれてしまっているケース。Martin Fowlerはこれを**貧血ドメインモデル(Anemic Domain Model)**と呼び、オブジェクト指向設計の基本的な利点を失わせるアンチパターンとして紹介している( Martin Fowler, AnemicDomainModel )。不変条件を守る責任は、外部のサービスではなく集約ルート自身のメソッドが持つべきである。前章のコード例でaddLineplaceOrder自身のメソッドになっているのはそのためだ。

トランザクションと結果整合性

Vernonの原則1が示す通り、1つのトランザクションで更新してよい集約は原則として1つだけである。これは制約というより、集約境界を正しく引けていれば自然にそうなる、という帰結に近い。真の不変条件で結びついたオブジェクト群が1つの集約であり、そのすべてが1トランザクションの中で整合していればよいのだから、複数の集約を同じトランザクションでまとめて更新する必要は本来生じないはずである。

では、集約をまたいだ整合性はどう実現するのか。答えはドメインイベントによる結果整合性(eventual consistency)である。

1トランザクション1集約と結果整合性 図2: Order集約の確定はトランザクション1で完結し、OrderPlacedドメインイベントを発行する。在庫を管理するInventory集約は、そのイベントを受けて別のトランザクション2で非同期に更新される。2つの集約は結果整合性で連携する。

たとえば注文が確定(OrderPlaced)したら在庫を引き当てたい、という要件があるとする。「注文の確定」と「在庫の引き当て」を1つのトランザクションで同期的に行いたくなるが、これはOrderInventoryという2つの集約を1トランザクションで更新することを意味し、Vernonの原則に反する。

代わりに、Order集約は自分のトランザクションの中で確定処理を完結させ、OrderPlacedというドメインイベントを発行するだけにとどめる。Inventory集約は、そのイベントを購読して、別のトランザクションの中で在庫を引き当てる。この2つの処理の間にはわずかなタイムラグが生じうるが、最終的には整合した状態に収束する。これが結果整合性の考え方である。

結果整合性を採用すると、イベント処理が失敗したときの再試行や、二重処理を防ぐための冪等性の考慮が必要になり、即座の同期処理に比べて設計の負荷は上がる。しかし、その負荷を払ってでも集約を小さく保つことで、スケーラビリティと保守性を得るというのが、Vernonが一貫して主張しているトレードオフである。

まとめ

  • 集約とは、データ変更の単位として扱う関連オブジェクトのまとまりであり、単なるグルーピングではなく整合性を保証する責任の単位である。
  • 集約ルートが外部からの唯一の窓口であり、内部オブジェクトへの直接アクセスは許されない。
  • 集約の境界を決めるのは、トランザクションの都合ではなく、常に成り立っていなければならない真の不変条件である。
  • Vernonの4原則(真の不変条件を境界内で守る/小さく設計する/他集約へはIDで参照する/境界の外は結果整合性)は、集約を小さく疎結合に保つための実践的な指針である。
  • TypeScriptでの実装では、合計金額のように構造的に保証できる不変条件はフィールドとして重複させず、複数フィールドにまたがる不変条件はzodrefineなどでまとめて検証するのが実務的なバランスになる。
  • 巨大集約、集約間の直接参照、1トランザクションでの複数集約更新、貧血ドメインモデルは典型的な失敗パターンであり、いずれも「境界を不変条件で引く」という原則から外れたときに発生する。
  • 集約間の整合性はドメインイベントによる結果整合性で解決する。1トランザクション1集約を守ることが、スケーラブルなドメインモデルの土台になる。

よくある質問(FAQ)

Q. 集約とエンティティの違いは?

エンティティは識別子(ID)によって同一性が定まる単体のドメインオブジェクトである。一方、集約は1つ以上のエンティティや値オブジェクトを、真の不変条件でひとまとめにした整合性の単位であり、その中に必ず1つの集約ルート(エンティティ)を持つ。集約が単一のエンティティだけで構成される場合、そのエンティティ自身が集約ルートになる。すべてのエンティティが集約というわけではなく、集約の内部オブジェクトとして別の集約ルートに従属するエンティティも存在する。

Q. 集約はどのくらいの大きさにすべき?

Vernonの原則2の通り、できる限り小さくするのが基本である。最小構成はルートエンティティ1つだけの集約であり、そこに「これがないと真の不変条件を守れない」という要素だけを追加していく。関連していそうだから、後で使いそうだから、という理由で要素を増やすと巨大集約(神集約)につながる。

Q. 複数の集約を1トランザクションで更新したくなったら?

まず、それが本当に「即座に成り立たなければならない真の不変条件」なのかを疑うべきである。多くの場合はそうではなく、ドメインイベントによる結果整合性で十分に要件を満たせる。どうしても同期的な整合性が必要だと判断した場合は、モデリング自体を見直し、集約の境界の引き方が間違っていないか再検討する必要がある。

Q. 集約ルート以外を直接更新してはいけないのはなぜ?

集約ルートは、その集約の不変条件を守る責任を持つゲートキーパーだからである。内部オブジェクトを外部から直接変更できてしまうと、集約ルートの知らないところで状態が変化し、ルートが検証しているはずの不変条件がすり抜けてしまう。すべての変更が集約ルートのメソッドを経由することで、初めて不変条件の常時維持が保証される。

関連書籍

集約設計をさらに深く学ぶための定番書を挙げておく。本記事で引用したVernonの設計原則は2冊目で体系的に解説されている。

エリック・エヴァンスのドメイン駆動設計(エリック・エヴァンス、翔泳社)
実践ドメイン駆動設計(ヴォーン・ヴァーノン、翔泳社)

他の分野の定番書は エンジニアにおすすめの技術書10選 にまとめている。

参考文献