1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

初心者でもわかるようにまとめる ~ドメイン駆動設計とは~

Posted at

ドメイン駆動設計(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のいいところ

  1. みんなが同じ言葉で話せる

    • ❌ 「テーブルAとテーブルBをJOINして...」
    • ✅ 「会員が本を借りる」
  2. ビジネスのルールがわかりやすい

    class Member:
        def can_borrow(self) -> bool:
            return len(self.borrowed_books) < 5  # 5冊まで
    
  3. 変更に強い

    • 「貸出期間を2週間から3週間に変更」→ 1箇所変えるだけ!

最初の一歩

DDDを全部やるのは大変なので、まずは:

  1. 値オブジェクトから始める

    # ❌ こうじゃなくて
    email = "test@example.com"
    
    # ✅ こう
    @dataclass(frozen=True)
    class Email:
        value: str
    
  2. ビジネスの言葉をそのまま使う

    • クラス名、メソッド名を業務用語にする
    • Customer(顧客)、Order(注文)、Ship(発送)など
  3. レイヤーを意識する

    • ビジネスロジックはドメイン層に集める
    • データベース処理はインフラ層に分ける

これだけでも、コードがぐっとわかりやすくなりますよ!

参考文献

  • エリック・エヴァンス『ドメイン駆動設計』(翔泳社)
  • 成瀬允宣『ドメイン駆動設計入門』(技術評論社)
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?