本章の方針
本章は、これまでの章で断片的に触れてきたアンチパターンを、症状・根本原因・脱出法 の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つが複合する。
- オブジェクト指向よりトランザクションスクリプトに慣れている:手続き型の習慣が抜けず、Entity は「DB行に対応するDTO」としか捉えられない
- ORMがプレーンなオブジェクトを要求する:TypeORM・Prisma などが pub field のみをサポートする場合がある
- 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) // 数秒かかる
根本原因
- 「関連するもの=同じ集約」という誤解:「顧客に関係するから顧客の集約」と判断してしまう
- 集約境界=ER図の関連、と捉えている:DB設計をそのままドメイン設計に持ち込んでいる
- 「真の不変条件」を問うていない:同一トランザクションで守る必要があるかを検証していない
脱出法
ステップ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)
根本原因
- 「データは文字列と数値」という単純化の誘惑:型定義のコストを惜しむ
- Value Object の設計方針が定まっていない:「どの単位でVO化するか」が決まっていない
- 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 が量産される
根本原因
- Java/Spring 流のサービスクラスの習慣:Service に処理を書くのが「自然」だと感じる
- Entity のロジック定住場所がわからない:どの Entity に書くべきか判断に迷い、結果 Service に逃げる
- 「単一責任の原則」の誤適用: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個以上のメソッド
}
根本原因
- Repository とクエリサービスの混同:「Subscriptionを取得する処理は全部 Repository」と考えている
- 集約の粒度と読み取り要件を同じ境界で扱っている:書き込みの集約と読み取りのビューを分離していない
- 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のハンドラがさらに別のイベントを...
・「このイベントで何が起きるか」が誰にも説明できない
根本原因
- Domain Event を「とりあえずイベント化」の対象にしている:副作用を切り出す手段として濫用
- イベントカタログ(誰が何を発火/購読しているか)が無い:連鎖関係が暗黙知になっている
- 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
}
}
根本原因
- 層ごとに型を厳密に分離しすぎている:全ての境界に DTO を用意している
- クリーンアーキテクチャの過剰適用:「各層で型を分ける」教条を機械的に守っている
- 変換処理の価値を評価していない:変換で得られる独立性が、コストに見合うかを問うていない
脱出法
ステップ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の表記変えましたよね?」の確認が発生
根本原因
- ユビキタス言語を初期に設定して以降、維持するプロセスがない:定着のための継続的なメンテナンスがない
- エンジニア・PM・デザイナー・サポートが別々の語彙を使っている:共通語彙を作る場(Event Stormingなど)が無い
- コードのリファクタが用語変更に追従していない:用語が変わってもコードの識別子を変えない
脱出法
ステップ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が量産される
・「ビジネスルールを集約に集める」が達成されていない
根本原因
- DDDを「構造のフレームワーク」として採用している:中身より形を優先する
- プロジェクトの性質に関係なく全てのパターンを適用している:CRUDだけのアプリにDDDを持ち込んでいる
- 「戦術的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 のロールバック処理」が本体ロジックと同じ複雑度になる
根本原因
- 結果整合性の受容が組織的にできていない:ビジネスサイドが「即座に整合すべき」と思い込んでいる
- 「一時的な不整合」のUI表現を設計していない:処理中表示やペンディング状態がない
- 整合性のレベルを段階的に設計していない:全部を強整合性で守ろうとしている
脱出法
ステップ1:ビジネスルールごとに「許容される遅延」をヒアリングする
在庫は秒単位の整合が必要か、分単位で良いか、時間単位で良いか
ステップ2:強整合性が本当に必要なものだけを集約にまとめる
他は結果整合性で Domain Events 経由で連携
ステップ3:ペンディング状態をUI で表現する
「処理中」「保留中」「完了」といった状態を明示する
ステップ4:DCB を検討する(2026年の選択肢)
2軸以上にまたがる強整合ルールがある場合、DCBが解となり得る
アンチパターン分類まとめ表
| # | アンチパターン | 症状 | 根本原因 | 主な脱出法 |
|---|---|---|---|---|
| AP-01 | Anemic Domain Model | Entityがデータホルダ化 | 習慣・ORM・用語だけの採用 | 状態変更をメソッド化 |
| AP-02 | God Aggregate | 1集約に全てが集まる | 「関連=同集約」誤解 | 真の不変条件で分割 |
| AP-03 | Primitive Obsession | IDや金額がプリミティブ型 | VO設計方針の未定義 | Branded Type + VO導入 |
| AP-04 | Service Everything | 1ユースケース=1Service量産 | Entity定住場所が不明 | Entityメソッドに統合 |
| AP-05 | Repository as DAO | Repositoryにクエリ多数 | CQRS不採用 | Query Serviceに分離 |
| AP-06 | Event Spaghetti | イベント連鎖で追跡不能 | カタログ・観測性不足 | 連鎖2段制限+Tracing |
| AP-07 | DTO Pingpong | 変換コードが本体を圧迫 | クリーンアーキテクチャ過剰適用 | 変換は本当に必要な境界だけ |
| AP-08 | Ubiquitous Language Drift | 用語が層ごとにバラバラ | メンテナンス不足 | Glossary運用+レビュー観点化 |
| AP-09 | DDD Cargo Cult | 構造だけDDD風 | 形の採用が目的化 | 適用パターンを絞る |
| AP-10 | Eventual Consistency Denial | 全てを強整合で守ろうとする | 結果整合性の受容不足 | 遅延許容のヒアリング+DCB検討 |
本章のまとめ
・アンチパターンは症状だけでなく「根本原因」を理解することが重要
・同じアンチパターンでも、根本原因は複数の場合が多い
・脱出法は一度では終わらない。段階的に改善する
・アンチパターンの多くは「適用しすぎ」から生まれる
・「DDDを適用しない判断」も重要な選択肢
次章では、2026年現在のDDDをめぐる大きな論争を総覧する。アンチパターンの背景にある「DDDそのものへの批判」も正面から扱う。