ドメイン駆動設計(DDD)を誰でもわかるように解説
ドメイン駆動設計って何?
「ビジネスの言葉でプログラムを書こう!」という考え方です。
従来の問題
お店の人:「お客さんが商品をカートに入れて、注文するシステムが欲しいんだ」
プログラマー:「了解!じゃあ、テーブル1、テーブル2、テーブル3を作って...」
→ お互いに話が通じない!
DDDの解決方法
お店の人:「お客さんが商品をカートに入れて、注文する」
プログラマー:「了解!じゃあ、お客さんクラス、商品クラス、カートクラス、注文クラスを作ります」
→ 同じ言葉で話せる!
身近な例で理解しよう:図書館システム
図書館を例に、DDDの考え方を見ていきましょう。
1. エンティティ(Entity)= IDカードがあるもの
会員証を持っている人や、バーコードがついている本のことです。
田中さんが名前を変えても、会員番号001なら同じ人です。
class Member:
def __init__(self, member_id: str, name: str):
self.member_id = member_id # これが会員証番号(ID)
self.name = name # 名前は変わるかも
self.borrowed_books = []
2. 値オブジェクト(Value Object)= IDがないもの
住所や電話番号のように、中身が同じなら同じものです。
from dataclasses import dataclass
@dataclass(frozen=True) # 変更不可にする
class Address:
prefecture: str # 都道府県
city: str # 市区町村
street: str # 番地
def __str__(self):
return f"{self.prefecture}{self.city}{self.street}"
# 使い方
address1 = Address("東京都", "渋谷区", "1-2-3")
address2 = Address("東京都", "渋谷区", "1-2-3")
# 中身が同じなら同じ住所
print(address1 == address2) # True
3. 集約(Aggregate)= セットでまとめる
貸出を考えてみましょう。
- 「貸出」が親分(集約ルート)
- 本を1冊ずつバラバラに返却はできない
- 必ず「貸出」を通して操作する
from datetime import datetime, timedelta
from typing import List
class Book:
def __init__(self, isbn: str, title: str):
self.isbn = isbn
self.title = title
class Rental:
"""貸出の集約ルート"""
def __init__(self, member: Member):
self.rental_id = None # 後で設定
self.member = member
self.books: List[Book] = []
self.rental_date = datetime.now()
self.due_date = None
def add_book(self, book: Book):
"""本を追加"""
if len(self.books) >= 3:
raise ValueError("一度に3冊まで")
self.books.append(book)
def set_due_date(self, days: int = 14):
"""返却日を設定(デフォルト14日後)"""
self.due_date = self.rental_date + timedelta(days=days)
def is_overdue(self) -> bool:
"""延滞してる?"""
return datetime.now() > self.due_date
4. リポジトリ(Repository)= 倉庫係
データベースにしまったり、取り出したりする人です。
from abc import ABC, abstractmethod
class MemberRepository(ABC):
"""会員リポジトリのインターフェース"""
@abstractmethod
def find_by_id(self, member_id: str) -> Member:
"""IDで会員を探す"""
pass
@abstractmethod
def save(self, member: Member) -> None:
"""会員を保存"""
pass
# 実際の実装例(簡易版)
class InMemoryMemberRepository(MemberRepository):
def __init__(self):
self.members = {}
def find_by_id(self, member_id: str) -> Member:
return self.members.get(member_id)
def save(self, member: Member) -> None:
self.members[member.member_id] = member
5. ドメインサービス(Domain Service)= 特別な係
エンティティや値オブジェクトに属さない、複数のオブジェクトにまたがる処理を担当します。
class RentalService:
"""貸出サービス"""
def __init__(self, member_repo: MemberRepository):
self.member_repo = member_repo
def can_member_borrow(self, member: Member, book_count: int) -> bool:
"""会員が指定数の本を借りられるか判定"""
current_borrowed = len(member.borrowed_books)
return (current_borrowed + book_count) <= 5
def calculate_late_fee(self, rental: Rental) -> int:
"""延滞料金を計算"""
if not rental.is_overdue():
return 0
days_late = (datetime.now() - rental.due_date).days
return days_late * 100 # 1日100円
実際の流れを見てみよう
図書館で本を借りる流れをプログラムで表現します。
コードにするとこんな感じ:
def borrow_books(member_id: str, books: List[Book], member_repo: MemberRepository):
"""本を借りる処理"""
# 1. 会員を探す
member = member_repo.find_by_id(member_id)
if member is None:
print("会員が見つかりません")
return None
# 2. 借りられるかチェック
if len(member.borrowed_books) + len(books) > 5:
print("借りられる上限を超えています(5冊まで)")
return None
# 3. 新しい貸出を作る
rental = Rental(member)
for book in books:
rental.add_book(book)
rental.set_due_date(14) # 2週間後
# 4. 保存(実際はrentalRepositoryを使う)
print(f"貸出完了!返却日: {rental.due_date.strftime('%Y-%m-%d')}")
return rental
# 使ってみる
member_repo = InMemoryMemberRepository()
member = Member("001", "田中太郎")
member_repo.save(member)
book1 = Book("978-1234567890", "Pythonの基礎")
book2 = Book("978-0987654321", "DDD入門")
rental = borrow_books("001", [book1, book2], member_repo)
レイヤーって何?
システムを役割ごとに分ける考え方です。お店に例えると:
- レジ(プレゼンテーション層):お客さんの注文を受ける(画面やAPI)
- 店長(アプリケーション層):誰に何を作らせるか指示
- 料理人(ドメイン層):料理を作る ← 一番大事!ビジネスロジックはここ
- 倉庫(インフラ層):食材を管理(データベース)
Pythonでのレイヤー構成例
# ===== ドメイン層 =====
# ビジネスルールを表現
class Member:
def __init__(self, member_id: str, name: str):
self.member_id = member_id
self.name = name
self.borrowed_books = []
def can_borrow(self) -> bool:
"""5冊まで借りられる(ビジネスルール)"""
return len(self.borrowed_books) < 5
# ===== アプリケーション層 =====
# ユースケースを実現
class BorrowBooksUseCase:
def __init__(self, member_repo: MemberRepository):
self.member_repo = member_repo
def execute(self, member_id: str, books: List[Book]):
"""本を借りるユースケース"""
member = self.member_repo.find_by_id(member_id)
if not member.can_borrow():
raise Exception("借りられる上限を超えています")
rental = Rental(member)
for book in books:
rental.add_book(book)
return rental
# ===== プレゼンテーション層 =====
# ユーザーとのやりとり
def borrow_books_api(member_id: str, book_ids: List[str]):
"""APIエンドポイント(FastAPIなどを想定)"""
# リクエストを受け取る
books = [get_book(book_id) for book_id in book_ids]
# アプリケーション層を呼び出す
use_case = BorrowBooksUseCase(member_repo)
rental = use_case.execute(member_id, books)
# レスポンスを返す
return {
"rental_id": rental.rental_id,
"due_date": rental.due_date
}
# ===== インフラ層 =====
# データベース接続など
class DatabaseMemberRepository(MemberRepository):
def find_by_id(self, member_id: str) -> Member:
# 実際のDB接続処理
row = db.execute("SELECT * FROM members WHERE id = ?", member_id)
return Member(row['id'], row['name'])
より実践的な例:値オブジェクト
メールアドレスの例
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Email:
value: str
def __post_init__(self):
"""初期化後のチェック"""
if not self._is_valid(self.value):
raise ValueError(f"無効なメールアドレス: {self.value}")
def _is_valid(self, email: str) -> bool:
"""メールアドレスの形式チェック"""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
return re.match(pattern, email) is not None
def get_domain(self) -> str:
"""ドメイン部分を取得"""
return self.value.split('@')[1]
# 使い方
try:
email1 = Email("test@example.com") # OK
print(f"ドメイン: {email1.get_domain()}") # example.com
email2 = Email("invalid-email") # ValueError
except ValueError as e:
print(e)
金額の例
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class Money:
amount: Decimal
currency: str = "JPY"
def __post_init__(self):
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)
def multiply(self, times: int) -> 'Money':
"""金額を掛ける"""
return Money(self.amount * times, self.currency)
def __str__(self):
return f"¥{self.amount:,}" if self.currency == "JPY" else f"{self.amount} {self.currency}"
# 使い方
price = Money(Decimal("1000"))
total = price.multiply(3)
print(total) # ¥3,000
境界づけられたコンテキスト
大きなシステムは複数の「コンテキスト」に分割します。
それぞれのコンテキストで「商品」の意味が異なっても問題ありません。
- 販売コンテキストの商品:価格、販売可能数
- 在庫コンテキストの商品:保管場所、ロット番号
ユビキタス言語
開発者とビジネス側が同じ言葉を使うことが重要です。
良い例
class Order:
"""注文"""
def place(self):
"""注文を確定する"""
pass
def cancel(self):
"""注文をキャンセルする"""
pass
class Cart:
"""カート"""
def add_item(self, product):
"""商品をカートに追加"""
pass
悪い例
class Data1:
def process(self):
pass
class Manager:
def do_something(self):
pass
まとめ
DDDの主要パターン
| パターン | 説明 | 例 |
|---|---|---|
| エンティティ | IDを持つオブジェクト | 会員、本 |
| 値オブジェクト | IDを持たず、値で判断 | 住所、メールアドレス、金額 |
| 集約 | 関連オブジェクトのまとまり | 注文と注文明細 |
| リポジトリ | データの永続化を担当 | MemberRepository |
| ドメインサービス | 複数オブジェクトにまたがる処理 | 送金サービス |
DDDのいいところ
-
みんなが同じ言葉で話せる
- ❌ 「テーブルAとテーブルBをJOINして...」
- ✅ 「会員が本を借りる」
-
ビジネスのルールがわかりやすい
class Member: def can_borrow(self) -> bool: return len(self.borrowed_books) < 5 # 5冊まで -
変更に強い
- 「貸出期間を2週間から3週間に変更」→ 1箇所変えるだけ!
最初の一歩
DDDを全部やるのは大変なので、まずは:
-
値オブジェクトから始める
# ❌ こうじゃなくて email = "test@example.com" # ✅ こう @dataclass(frozen=True) class Email: value: str -
ビジネスの言葉をそのまま使う
- クラス名、メソッド名を業務用語にする
-
Customer(顧客)、Order(注文)、Ship(発送)など
-
レイヤーを意識する
- ビジネスロジックはドメイン層に集める
- データベース処理はインフラ層に分ける
これだけでも、コードがぐっとわかりやすくなりますよ!
参考文献
- エリック・エヴァンス『ドメイン駆動設計』(翔泳社)
- 成瀬允宣『ドメイン駆動設計入門』(技術評論社)