目次を表示する

戦術的DDD実践ガイド 2026

アンチパターン・カタログ ── 実践で踏んだ地雷を分類する

本章の方針

本章は、これまでの章で断片的に触れてきたアンチパターンを、症状・根本原因・脱出法 の3点セットで体系化する。Zenn執筆原則11(ベストプラクティスとアンチパターンはセットで、特にアンチパターンは症状/根本原因/脱出法の形式)に忠実に従う。

合計10個のアンチパターンを扱う。各パターンは独立して読めるが、最後の分類表でパターン間の関係を整理する。


AP-01:Anemic Domain Model(貧血ドメインモデル)

症状

// Entity が単なるデータホルダになっている
class Subscription {
  public id: SubscriptionId
  public customerId: CustomerId
  public planId: PlanId
  public status: SubscriptionStatus
  public currentPeriod: BillingPeriod
}

// ロジックは全て Service に書かれている
class SubscriptionService {
  upgrade(sub: Subscription, newPlan: Plan) {
    if (sub.status !== 'active') throw ...
    sub.planId = newPlan.id
    sub.status = 'active'
    // ...
  }
}

根本原因

3つが複合する。

  1. オブジェクト指向よりトランザクションスクリプトに慣れている:手続き型の習慣が抜けず、Entity は「DB行に対応するDTO」としか捉えられない
  2. ORMがプレーンなオブジェクトを要求する:TypeORM・Prisma などが pub field のみをサポートする場合がある
  3. DDDを「用語だけ」採用した:「Entity クラスを作った」で満足し、その中にロジックを集める段階まで進んでいない

脱出法

ステップ1:Service の中の状態変更ロジックを洗い出す
  (「sub.xxx = yyy」のような代入)

ステップ2:対応するメソッドを Entity に移す
  subscriptionService.upgrade(sub, newPlan) → sub.upgrade(newPlan)

ステップ3:Entity のフィールドを private にし、セッタを全廃する
  状態変更は全てメソッド経由に変える

ステップ4:ORM のマッピングで private フィールドが扱えるように設定
  ORM固有の設定(例:Prismaなら @map、TypeORMなら @Column({ select: false }) )

AP-02:God Aggregate(神集約)

症状

// 1つの集約が全てを抱え込む
class Customer {
  private subscriptions: Subscription[]    // 数十件
  private invoices: Invoice[]              // 数百件
  private paymentMethods: PaymentMethod[]  // 複数件
  private usageRecords: UsageRecord[]      // 数千件
  private auditLogs: AuditLog[]            // 数万件
}

// Customer をロードするだけで全履歴を読み込む
const customer = await customerRepo.findById(id)  // 数秒かかる

根本原因

  1. 「関連するもの=同じ集約」という誤解:「顧客に関係するから顧客の集約」と判断してしまう
  2. 集約境界=ER図の関連、と捉えている:DB設計をそのままドメイン設計に持ち込んでいる
  3. 「真の不変条件」を問うていない:同一トランザクションで守る必要があるかを検証していない

脱出法

ステップ1:集約ロード時に読むEntity数を計測する
  N > 10 なら分割候補、N > 100 ならほぼ確実に分割すべき

ステップ2:Entity ごとに「他と同時更新しないと違反するビジネスルール」を列挙する
  同時更新が本当に必要なものだけを同一集約に残す

ステップ3:他の Entity は別集約として切り出す
  集約間の参照は ID のみ(オブジェクト参照はしない)

ステップ4:集約間の整合性が必要なら Domain Events で連携する
  強整合性が必須なら DCB の検討を

AP-03:Primitive Obsession(プリミティブ型病)

症状

// 意味の違うものが同じ型で扱われている
function charge(
  subscriptionId: string,  // cus_* と取り違え可能
  customerId: string,
  amount: number,          // JPY か USD か区別できない
  currency: string,        // 任意文字列
) { ... }

// 引数順を間違えても型検査で止まらない
charge(customerId, subscriptionId, amount, currency)

根本原因

  1. 「データは文字列と数値」という単純化の誘惑:型定義のコストを惜しむ
  2. Value Object の設計方針が定まっていない:「どの単位でVO化するか」が決まっていない
  3. API境界の型定義に引きずられる:OpenAPIやGraphQLの型がプリミティブなので、ドメイン層もそれに合わせてしまう

脱出法

ステップ1:ID型を Branded Type に置き換える
  type SubscriptionId = string & { __brand: 'SubscriptionId' }

ステップ2:金額・期間など振る舞いを持つ値をVO化する
  Money, BillingPeriod, Proration など

ステップ3:API境界で「プリミティブ ⇔ VO」の変換を明示する
  Controller や Application Service で変換する

ステップ4:プリミティブな型注釈を禁止するESLintルールを入れる
  ドメイン層では string / number の直接使用を警告

AP-04:Service Everything(なんでもService)

症状

// Entity にロジックを書かず、全てをServiceに委ねている
class SubscriptionUpgradeService { ... }
class SubscriptionCancelService { ... }
class SubscriptionActivationService { ... }
class SubscriptionRenewalService { ... }
class SubscriptionDowngradeService { ... }
// ... 1ユースケースにつき1Service が量産される

根本原因

  1. Java/Spring 流のサービスクラスの習慣:Service に処理を書くのが「自然」だと感じる
  2. Entity のロジック定住場所がわからない:どの Entity に書くべきか判断に迷い、結果 Service に逃げる
  3. 「単一責任の原則」の誤適用:1クラス1ユースケースに機械的に分ける

脱出法

ステップ1:Service の処理を「対象となる Entity」で分類する
  Subscription に触れる処理は SubscriptionUpgradeService に集まっている

ステップ2:対応する Entity のメソッドに統合する
  subscription.upgrade(newPlan)
  subscription.cancel(asOf)
  subscription.activate()
  ...

ステップ3:残ったものだけ Application Service として整理する
  Application Service は「集約の取得 → メソッド呼び出し → 保存」のみ
  UseCase単位でまとめる(例:UpgradeSubscriptionHandler)

ステップ4:どうしても残るものだけ Domain Service として隔離する
  為替変換、信用スコアリング、重量計算など「どの集約にも属さない」もの

AP-05:Repository as DAO(Repository がDAO化)

症状

interface SubscriptionRepository {
  findById(id): Promise<Subscription>
  findByCustomerId(id): Promise<Subscription[]>
  findByStatus(status): Promise<Subscription[]>
  findByPlan(planId): Promise<Subscription[]>
  findCreatedBetween(from, to): Promise<Subscription[]>
  findExpiring(within): Promise<Subscription[]>
  findWithComplexFilter(filter: ComplexFilter): Promise<Subscription[]>
  countByStatus(): Promise<Map<string, number>>
  // ...30個以上のメソッド
}

根本原因

  1. Repository とクエリサービスの混同:「Subscriptionを取得する処理は全部 Repository」と考えている
  2. 集約の粒度と読み取り要件を同じ境界で扱っている:書き込みの集約と読み取りのビューを分離していない
  3. CQRSを知らない/採用していない:読み書きで異なるモデルを使う発想がない

脱出法

ステップ1:Repository メソッドを「集約単位の操作」と「クエリ」に分類する
  findById, save → 集約単位
  findByXxx, findAll, countBy → クエリ

ステップ2:クエリ系を Query Service として分離する
  class SubscriptionListQuery { ... }
  class SubscriptionStatsQuery { ... }

ステップ3:Query Service では Entity を介さず、直接 DTO を返す
  SQLで必要なフィールドだけ取得し、DTOに詰める

ステップ4:Repository は集約の「取得(findById)」と「保存(save)」だけに絞る
  3〜5メソッドに収まるのが理想

AP-06:Event Spaghetti(イベントスパゲッティ)

症状

・ドメインイベントが20個以上
・1イベントにハンドラが5個以上購読
・ハンドラAが別のイベントBを発火、Bのハンドラがさらに別のイベントを...
・「このイベントで何が起きるか」が誰にも説明できない

根本原因

  1. Domain Event を「とりあえずイベント化」の対象にしている:副作用を切り出す手段として濫用
  2. イベントカタログ(誰が何を発火/購読しているか)が無い:連鎖関係が暗黙知になっている
  3. Distributed Tracing が導入されていない:実行時の依存が可視化されていない

脱出法

ステップ1:イベントカタログを作る
  | イベント名 | 発火元 | 購読者 | 連鎖するイベント |
  を表形式で README や Notion に記録する

ステップ2:イベント連鎖を2段まで、というルールを設ける
  A → B → C のような連鎖を禁止し、Application Service で明示的に連携する

ステップ3:OpenTelemetryで Distributed Tracing を導入する
  1リクエストで何が起きたかをトレースで追えるようにする

ステップ4:「そのイベントは本当に必要か」を定期的に見直す
  購読者がいないイベントは削除する

AP-07:DTO Pingpong(DTO往復症候群)

症状

// 層間の変換だけで大量のコードが書かれている
class UpgradeController {
  async upgrade(req: UpgradeRequest): Promise<UpgradeResponse> {
    const dto = this.toDto(req)        // API DTO → Command DTO
    const appDto = this.toAppDto(dto)  // Command DTO → Application DTO
    const result = await this.service.execute(appDto)
    const apiDto = this.toApiDto(result)  // Result → API DTO
    return apiDto
  }
}

根本原因

  1. 層ごとに型を厳密に分離しすぎている:全ての境界に DTO を用意している
  2. クリーンアーキテクチャの過剰適用:「各層で型を分ける」教条を機械的に守っている
  3. 変換処理の価値を評価していない:変換で得られる独立性が、コストに見合うかを問うていない

脱出法

ステップ1:層間の型を「どこで本当に違うか」を洗い出す
  API DTO と Command が同じ構造なら、統一する

ステップ2:本当に独立性が必要な境界だけに変換を残す
  HTTP 境界:API DTO ⇔ Command(必要)
  Application 内部:Command = Application 内の型(同一でよい)
  Domain 境界:Application型 → ドメイン型(必要)

ステップ3:変換関数は1箇所にまとめ、テスト可能にする
  Controllerに散らばる変換を Mapper として分離

ステップ4:型の違いが「無意味な再宣言」なら統一する
  Commandと Application DTOが同じフィールドの場合、片方を消す

AP-08:Ubiquitous Language Drift(ユビキタス言語の蒸発)

症状

・コードでは Subscription、UI では Plan、DBでは contracts、
  ドキュメントでは 契約 と呼ばれている
・新入社員が用語の対応表を作るところから始める
・リリースのたびに「これってUIの表記変えましたよね?」の確認が発生

根本原因

  1. ユビキタス言語を初期に設定して以降、維持するプロセスがない:定着のための継続的なメンテナンスがない
  2. エンジニア・PM・デザイナー・サポートが別々の語彙を使っている:共通語彙を作る場(Event Stormingなど)が無い
  3. コードのリファクタが用語変更に追従していない:用語が変わってもコードの識別子を変えない

脱出法

ステップ1:用語集(Glossary)をリポジトリに追加する
  GLOSSARY.md で「英語名 / 日本語名 / 意味 / 関連用語」を管理

ステップ2:各境界づけられたコンテキスト内での用語統一を徹底する
  UI・API・ドメイン層・DB で用語を揃える

ステップ3:用語変更をRefactoring Plan に含める
  PdM や Designer が「契約」→「サブスクリプション」に変える際、
  コードベースも同時に変える

ステップ4:Code Review の観点に「ユビキタス言語の一貫性」を加える
  命名の不一致をPRコメントで指摘する習慣を作る

AP-09:DDD Cargo Cult(DDDカーゴカルト)

症状

・Entity / ValueObject / Repository / Service / Factory の
  ディレクトリを必ず作っている
・小規模プロジェクトでも「まず DDD 構造を整える」から始める
・既存コードをDDD構造に揃えるだけのPRが量産される
・「ビジネスルールを集約に集める」が達成されていない

根本原因

  1. DDDを「構造のフレームワーク」として採用している:中身より形を優先する
  2. プロジェクトの性質に関係なく全てのパターンを適用している:CRUDだけのアプリにDDDを持ち込んでいる
  3. 「戦術的DDDパターンを使う」が目的化している:本来の目的(ドメインの複雑さに向き合う)が失われている

脱出法

ステップ1:プロジェクトの「ドメインの複雑さ」を評価する
  単純なCRUDなら、DDDは不要かもしれない
  ドメインルールが豊富で、ビジネスチームと継続的な対話が必要なら DDD が有効

ステップ2:適用するパターンを絞る
  Value Object と Application Service だけでも十分なケースが多い
  Aggregate と Domain Service は必要性が明確になってから導入

ステップ3:「構造を整える PR」を禁止する
  リファクタは「ビジネス価値の改善」を伴うときだけ行う

ステップ4:DDDではなく Vertical Slice や 関数型設計 が適合する可能性を検討
  Ch.9 の2026年論争を参照

AP-10:Eventual Consistency Denial(結果整合性の否認)

症状

・集約境界を越えた強整合性を保ちたい、という要求が次々と出る
・「トランザクション内で全部やれば良いじゃない」と2PC的な実装に走る
・結果「全てが1つの巨大集約」になる
・あるいは「Saga のロールバック処理」が本体ロジックと同じ複雑度になる

根本原因

  1. 結果整合性の受容が組織的にできていない:ビジネスサイドが「即座に整合すべき」と思い込んでいる
  2. 「一時的な不整合」のUI表現を設計していない:処理中表示やペンディング状態がない
  3. 整合性のレベルを段階的に設計していない:全部を強整合性で守ろうとしている

脱出法

ステップ1:ビジネスルールごとに「許容される遅延」をヒアリングする
  在庫は秒単位の整合が必要か、分単位で良いか、時間単位で良いか

ステップ2:強整合性が本当に必要なものだけを集約にまとめる
  他は結果整合性で Domain Events 経由で連携

ステップ3:ペンディング状態をUI で表現する
  「処理中」「保留中」「完了」といった状態を明示する

ステップ4:DCB を検討する(2026年の選択肢)
  2軸以上にまたがる強整合ルールがある場合、DCBが解となり得る

アンチパターン分類まとめ表

#アンチパターン症状根本原因主な脱出法
AP-01Anemic Domain ModelEntityがデータホルダ化習慣・ORM・用語だけの採用状態変更をメソッド化
AP-02God Aggregate1集約に全てが集まる「関連=同集約」誤解真の不変条件で分割
AP-03Primitive ObsessionIDや金額がプリミティブ型VO設計方針の未定義Branded Type + VO導入
AP-04Service Everything1ユースケース=1Service量産Entity定住場所が不明Entityメソッドに統合
AP-05Repository as DAORepositoryにクエリ多数CQRS不採用Query Serviceに分離
AP-06Event Spaghettiイベント連鎖で追跡不能カタログ・観測性不足連鎖2段制限+Tracing
AP-07DTO Pingpong変換コードが本体を圧迫クリーンアーキテクチャ過剰適用変換は本当に必要な境界だけ
AP-08Ubiquitous Language Drift用語が層ごとにバラバラメンテナンス不足Glossary運用+レビュー観点化
AP-09DDD Cargo Cult構造だけDDD風形の採用が目的化適用パターンを絞る
AP-10Eventual Consistency Denial全てを強整合で守ろうとする結果整合性の受容不足遅延許容のヒアリング+DCB検討

本章のまとめ

・アンチパターンは症状だけでなく「根本原因」を理解することが重要
・同じアンチパターンでも、根本原因は複数の場合が多い
・脱出法は一度では終わらない。段階的に改善する
・アンチパターンの多くは「適用しすぎ」から生まれる
・「DDDを適用しない判断」も重要な選択肢

次章では、2026年現在のDDDをめぐる大きな論争を総覧する。アンチパターンの背景にある「DDDそのものへの批判」も正面から扱う。