はじめに
「DDDに入門しようとしたら、本が分厚すぎて挫折した…」
「エンティティ?集約?リポジトリ?用語が多すぎて、どれが何なのかわからない…」
ドメイン駆動設計(DDD)の学習で最初につまずくのは、設計思想そのものよりも用語の壁です。私も最初はカタカナ用語の多さに圧倒されて、原典を読む手が止まりました。
そこで本記事では、DDDの頻出用語10個を、それぞれ10行前後のPythonコードとセットで一気に解説します。難しい理論は後回しにして、「コードで見ればこういうことか」という直感を先に作るのが狙いです。
この記事を読むと、次の状態になれます。
- DDDの頻出用語10個を、自分の言葉で説明できる
- 用語同士の関係(何が何を含むのか)が1枚の図で頭に入る
- 「DDDをどこまで取り入れるべきか」の判断基準を持てる
- エヴァンス本などの専門書を読む準備が整う
対象者
- DDDという言葉は聞いたことがあるが、中身はよく知らない人
- DDD本を1度開いて閉じたことがある人
- Pythonの基本文法(クラス、型ヒント)がわかる人
- 読了目安: 約15分
本記事のコードは Python 3.12 で動作確認しています(dataclasses と typing.Protocol を使用します)。
全体像 — 用語は2つのグループに分かれる
DDDの用語は「戦略的設計(システムをどう分割するか)」と「戦術的設計(コードをどう書くか)」の2グループに分けると、一気に見通しが良くなります。
| グループ | 問い | 本記事で扱う用語 |
|---|---|---|
| 戦略的設計 | システムをどう分割し、チームでどう言葉を揃えるか | ①ユビキタス言語 ②境界づけられたコンテキスト |
| 戦術的設計 | 分割した中身を、どんな部品で実装するか | ③値オブジェクト ④エンティティ ⑤集約 ⑥リポジトリ ⑦ドメインサービス ⑧ドメインイベント ⑨アプリケーションサービス |
| アンチパターン | やってはいけない形 | ⑩貧血ドメインモデル |
戦術的設計の部品同士は、次の関係になっています。この図を頭に置いて読み進めてください。
① ユビキタス言語 — チーム全員で同じ言葉を使う
定義: 開発者・企画・ドメインエキスパート(業務に詳しい人)が、会話でもコードでも同じ言葉を使うこと。
DDDの出発点は技術ではなく言葉です。会話では「注文をキャンセルする」と言っているのに、コードでは update_status(2) と書かれていたら、仕様とコードの対応が失われます。
# Bad: 会話に出てこない言葉でコードが書かれている
order.update_status(2) # 2って何…?
# Good: 会話の言葉がそのままコードに現れる
order.cancel() # 「注文をキャンセルする」がそのまま読める
ポイント:
- 用語集を作って会話とコードの言葉を揃える: 「会員?ユーザー?顧客?」の揺れを最初に潰す
- コードレビューで「この名前、業務の言葉と合ってる?」と問うだけでもDDDの第一歩です
② 境界づけられたコンテキスト — 言葉が通じる範囲で区切る
定義: 1つの用語が1つの意味で通じる範囲を境界として、システムを分割する考え方。
同じ「商品」という言葉でも、販売の文脈では「価格や在庫」、配送の文脈では「重さやサイズ」が関心事です。これを1つの巨大な Product クラスに詰め込むと、あらゆる部署の都合が混ざった神クラス(何でも屋の巨大クラス)が生まれます。
ポイント:
- 「全部署共通の完璧な商品モデル」を作ろうとしない。文脈ごとに別のモデルを持つ方が変更に強い
- マイクロサービスの分割単位の議論は、この概念が土台になっています
③ 値オブジェクト — 「値」に名前とルールを与える
定義: IDを持たず、値そのものが本体である不変のオブジェクト。
金額を int のまま扱うと、「マイナスの金額」や「円とドルの足し算」のようなバグを型で防げません。値オブジェクトにすると、ルールが型に染み込みます。
from dataclasses import dataclass
@dataclass(frozen=True) # frozen=True で不変にするのが値オブジェクトの流儀
class Money:
amount: int
currency: str = "JPY"
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError("金額は0以上である必要があります")
def add(self, other: "Money") -> "Money":
if self.currency != other.currency:
raise ValueError("通貨が異なる金額は加算できません")
return Money(self.amount + other.amount, self.currency)
>>> Money(1000).add(Money(500))
Money(amount=1500, currency='JPY')
>>> Money(-100)
ValueError: 金額は0以上である必要があります
ポイント:
-
等価性は「値が同じか」で判断(
Money(1000) == Money(1000)はTrue) - 変更したいときは新しいインスタンスを返す(
addが新しいMoneyを返しているのに注目) - 「金額」「メールアドレス」「郵便番号」など、検証ルール付きのプリミティブ値はすべて候補です
④ エンティティ — 「同一性」をIDで判断する
定義: 属性が変わっても「同じもの」として追跡したい対象。同一性はIDで判断する。
会員が改名しても、その人は同じ会員です。「値がすべて同じなら同じ」の値オブジェクトとは、等価性の考え方が正反対です。
from dataclasses import dataclass, field
import uuid
@dataclass(eq=False)
class Member:
name: str
id: str = field(default_factory=lambda: str(uuid.uuid4()))
def rename(self, new_name: str) -> None:
if not new_name:
raise ValueError("名前は空にできません")
self.name = new_name
def __eq__(self, other: object) -> bool:
if not isinstance(other, Member):
return NotImplemented
return self.id == other.id # 名前が違ってもIDが同じなら同一人物
ポイント:
- 迷ったら「属性が変わっても追いかけたいか?」と自問する。Yesならエンティティ、Noなら値オブジェクト
- エンティティの属性はできるだけ値オブジェクトで構成すると、検証ロジックが散らばりません
⑤ 集約 — 整合性を守る「ひとかたまり」
定義: 必ず一緒に整合性を保つべきオブジェクトのまとまり。外部からは代表(集約ルート)経由でしか触らせない。
「注文の合計は10万円まで」というルールがあるとき、注文明細を誰でも直接追加できたら、ルールはすり抜けられます。入口を1つに絞るのが集約です。
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass(frozen=True)
class OrderLine: # 集約の内部部品(値オブジェクト)
product_name: str
price: Money
@dataclass
class Order: # 集約ルート(エンティティ)
id: str
lines: list[OrderLine] = field(default_factory=list)
LIMIT: ClassVar[Money] = Money(100_000)
def add_line(self, line: OrderLine) -> None:
new_total = self.total().add(line.price)
if new_total.amount > self.LIMIT.amount:
raise ValueError("注文合計は10万円を超えられません") # ルールはここで必ず守られる
self.lines.append(line)
def total(self) -> Money:
total = Money(0)
for line in self.lines:
total = total.add(line.price)
return total
ポイント:
-
order.lines.append(...)と直接書かず、必ずorder.add_line(...)を通す。これだけで「ルールの抜け道」が消える - 集約はトランザクションの単位でもあります。1回の保存で守るべき範囲=1集約が目安
- 集約を大きくしすぎない。「本当に同時に整合性が必要か?」で切り分けます
⑥ リポジトリ — 永続化の詳細をドメインから隠す
定義: 集約の保存・取得を抽象化する出入り口。ドメイン層はDBの存在を知らない。
from typing import Protocol
class OrderRepository(Protocol): # ドメイン層はこのインターフェースだけを知る
def save(self, order: Order) -> None: ...
def find_by_id(self, order_id: str) -> Order | None: ...
class InMemoryOrderRepository: # 実装はインフラ層に置く(テスト用の例)
def __init__(self) -> None:
self._store: dict[str, Order] = {}
def save(self, order: Order) -> None:
self._store[order.id] = order
def find_by_id(self, order_id: str) -> Order | None:
return self._store.get(order_id)
ポイント:
- ドメイン層のコードに
SELECT文や ORM(Model.objects.filter(...)など)が現れなくなり、業務ルールのテストがDBなしで書ける - 本番実装(PostgreSQL版など)とテスト実装(インメモリ版)を差し替えられる
- リポジトリは集約単位で作ります(
OrderLineRepositoryは作らない)
⑦ ドメインサービス — どのオブジェクトにも属さないルールの置き場
定義: 値オブジェクトにもエンティティにも自然に置けない業務ルールを担うクラス・関数。
「メールアドレスの重複チェック」は、Member 単体では判定できません(他の全会員を知らないため)。こういう「複数のオブジェクトにまたがるルール」の置き場がドメインサービスです。
class MemberService:
def __init__(self, repository: MemberRepository) -> None:
self._repository = repository
def is_duplicated(self, member: Member) -> bool:
existing = self._repository.find_by_email(member.email)
return existing is not None and existing != member
迷ったらまずエンティティ・値オブジェクトに置けないか考えてください。何でもドメインサービスに置くと、ロジックがモデルから流出して後述の「貧血ドメインモデル」に近づきます。
⑧ ドメインイベント — 「起きた事実」をオブジェクトにする
定義: ドメインで起きた出来事(過去形の事実)を表す不変オブジェクト。
「注文が確定したら、確認メールを送り、在庫を引き当てる」。この「〜たら」をイベントとして切り出すと、注文処理とメール送信を疎結合にできます。
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class OrderPlaced: # 名前は必ず過去形にする
order_id: str
total: Money
occurred_at: datetime
ポイント:
- イベント名は過去形(
OrderPlaced/MemberRegistered)。「事実」なので後から変更されない - 受け手(メール送信、在庫引当…)を増やしても、注文のコードは変わらない
⑨ アプリケーションサービス — ユースケースの進行役
定義: 「注文する」「退会する」といったユースケース1つを、ドメインの部品を組み合わせて進行させる層。
class PlaceOrderUseCase:
def __init__(self, repository: OrderRepository) -> None:
self._repository = repository
def execute(self, order_id: str, line: OrderLine) -> None:
order = self._repository.find_by_id(order_id) # 1. 取得
if order is None:
raise ValueError(f"注文が見つかりません: {order_id}")
order.add_line(line) # 2. ドメインのルールで操作
self._repository.save(order) # 3. 保存
ポイント:
- アプリケーションサービスは「取得→依頼→保存」の進行役に徹し、業務ルール(if文)を持たないのが理想
- 業務ルールが書きたくなったら、それは集約かドメインサービスに置くべきサインです
- FastAPIなどのコントローラからこの
executeを呼ぶ構成にすると、Webフレームワークとドメインが分離できます
⑩ 貧血ドメインモデル — 一番よくあるアンチパターン
定義: モデルがgetter/setterだけのデータ入れ物になり、ルールがすべてサービス側に流出した状態。
DDDの用語だけ取り入れると、高確率でこの形になります。Bad→Goodで見比べてください。
# Bad: Orderはただのデータ入れ物。ルールはサービスに散らばる
@dataclass
class Order:
id: str
lines: list[OrderLine]
class OrderService:
def add_line(self, order: Order, line: OrderLine) -> None:
total = sum(l.price.amount for l in order.lines) + line.price.amount
if total > 100_000: # ルールがモデルの外にある
raise ValueError("上限超過") # → 別のサービスが検証を忘れたら終わり
order.lines.append(line)
# Good: ルールはOrder自身が持つ(⑤集約のコードと同じ形)
order.add_line(line) # どこから呼んでも上限ルールが必ず効く
見分け方: モデルのメソッドが get_◯◯/set_◯◯ ばかりで、if 文がサービスクラスに集中していたら貧血です。「データを持つ者がルールも持つ」に寄せていくのが対策です。
マーチン・ファウラーによる原典の解説はこちらです。
DDDを「やりすぎない」ための判断基準
DDDは万能ではありません。次の使い分けが安全です。
| 状況 | DDDの取り入れ度 |
|---|---|
| 管理画面などのただのCRUD(作成・読取・更新・削除だけの処理) | 不要。ORMのモデルをそのまま使う方が速い |
| 業務ルールが少ないマイクロサービス | 値オブジェクトだけ「つまみ食い」する |
| 複雑な業務ルールが中心のシステム | 戦術的設計をフル活用する価値あり |
| 複数チーム・複数部署が関わる大規模開発 | 戦略的設計(コンテキスト分割)から始める |
「つまみ食い」は正当な戦略です。値オブジェクト(③)と貧血モデル対策(⑩)だけでも、コードの堅牢さは目に見えて変わります。
まとめ — この3つだけ持ち帰ってください
10個の用語を紹介しましたが、特に重要なのは次の3点です。
- 言葉を揃える(①②): 会話の言葉をそのままコードに。1つの意味が通じる範囲でシステムを区切る
- ルールをデータの持ち主に置く(③④⑤⑩): 検証ルールは値オブジェクトと集約の中へ。貧血モデルを避ける
- ドメインを外界から隔離する(⑥⑨): DBやWebフレームワークの都合をリポジトリとユースケース層で堰き止める
次の一歩
本記事は入口です。体系的に学ぶなら、次の順番をおすすめします。
まずは日本語で書かれた入門書から。本記事の用語が丁寧に再登場します。
その後に原典へ。分厚いですが、用語の直感がある状態なら読み進められます。
原典のエッセンスだけをまとめた公式リファレンス(英語・無料)もあります。
解釈が分かれやすいテーマなので、「うちの現場ではこうしている」「この理解は違うのでは?」などあれば、ぜひコメントで教えてください。この記事は指摘を反映して更新していきます。