サザエさん一家で理解するドメイン駆動設計(DDD)入門

フリーランスボード

20万件以上の案件から、副業に最適なリモート・週3〜の案件を一括検索できるプラットフォーム。プロフィール登録でAIスカウトが自動的にマッチング案件を提案。市場統計や単価相場、エージェントの口コミも無料で閲覧可能なため、本業を続けながら効率的に高単価の副業案件を探せます。フリーランスボード

ITプロパートナーズ

週2〜3日から働ける柔軟な案件が業界トップクラスの豊富さを誇るフリーランスエージェント。エンド直契約のため高単価で、週3日稼働でも十分な報酬を得られます。リモートや時間フレキシブルな案件も多数。スタートアップ・ベンチャー中心で、トレンド技術を使った魅力的な案件が揃っています。専属エージェントが案件紹介から契約交渉までサポート。利用企業2,000社以上の実績。ITプロパートナーズ

Midworks 10,000件以上の案件を保有し、週3日〜・フルリモートなど柔軟な働き方に対応。高単価案件が豊富で、報酬保障制度(60%)や保険料負担(50%)など正社員並みの手厚い福利厚生が特徴。通勤交通費(月3万円)、スキルアップ費用(月1万円)の支給に加え、リロクラブ・freeeが無料利用可能。非公開案件80%以上、支払いサイト20日で安心して稼働できます。Midworks

ドメイン駆動設計(Domain-Driven Design、以下DDD)は、エリック・エヴァンスが提唱したソフトウェア設計手法です。ビジネスロジックを中心に据えた設計により、複雑なシステムを理解しやすく、保守しやすくします。

本記事では、誰もが知る「サザエさん」の磯野家・フグ田家を例に、DDDの主要な概念を分かりやすく解説します。Python実装例も交えながら、実践的な理解を深めていきましょう。

なぜサザエさんでDDDを学ぶのか

サザエさんの世界は、家族関係、職場、学校、商店街など、私たちの日常生活に近いドメイン(業務領域)が描かれています。このような身近な題材を使うことで、抽象的なDDDの概念を具体的にイメージしやすくなります。

DDDの基本概念とサザエさんの世界

1. ドメインモデルとは

ドメインとは、ソフトウェアが対象とする業務領域のことです。サザエさんの世界では「家族管理」「買い物」「勤怠管理」などがドメインに該当します。

ドメインモデルは、このドメインの概念をオブジェクトとして表現したものです。磯野家の家族構成や関係性をモデル化することで、システムとして扱えるようになります。

2. エンティティ(Entity)- 一意に識別できるオブジェクト

エンティティは**同一性(アイデンティティ)**を持つオブジェクトです。たとえ属性が変わっても、同じ対象として識別できるものを指します。

サザエさんでの例:

  • 磯野サザエさんは、結婚して「フグ田サザエ」になっても同一人物です
  • 磯野カツオくんは、身長が伸びても、成績が変わっても、カツオくんです

エンティティには一意の識別子(ID)が必要です。人間であれば、マイナンバーや社員番号のようなものです。

from dataclasses import dataclass
from typing import Optional
from datetime import date


@dataclass
class PersonId:
    """人物を一意に識別するID(値オブジェクト)"""
    value: str
    
    def __post_init__(self):
        if not self.value:
            raise ValueError("PersonIdは空にできません")


class Person:
    """人物エンティティ"""
    
    def __init__(
        self,
        person_id: PersonId,
        family_name: str,
        given_name: str,
        birth_date: date
    ):
        self._person_id = person_id
        self._family_name = family_name
        self._given_name = given_name
        self._birth_date = birth_date
        self._age = self._calculate_age()
    
    @property
    def person_id(self) -> PersonId:
        """識別子は変更不可"""
        return self._person_id
    
    @property
    def full_name(self) -> str:
        return f"{self._family_name} {self._given_name}"
    
    def change_family_name(self, new_family_name: str):
        """結婚などで姓が変わる場合"""
        self._family_name = new_family_name
    
    def _calculate_age(self) -> int:
        today = date.today()
        return today.year - self._birth_date.year
    
    def __eq__(self, other) -> bool:
        """同一性の判定はIDで行う"""
        if not isinstance(other, Person):
            return False
        return self._person_id.value == other._person_id.value
    
    def __hash__(self):
        return hash(self._person_id.value)


# 使用例
sazae = Person(
    person_id=PersonId("P001"),
    family_name="フグ田",
    given_name="サザエ",
    birth_date=date(1922, 11, 22)
)

katsuo = Person(
    person_id=PersonId("P002"),
    family_name="磯野",
    given_name="カツオ",
    birth_date=date(2014, 1, 1)
)

ポイント解説:

  • person_idという一意の識別子を持つため、サザエさんが結婚して姓が変わっても同一人物として扱えます
  • __eq__メソッドで同一性をIDで判定します(属性が変わっても同じオブジェクトとみなす)
  • 姓はchange_family_nameメソッドで変更可能ですが、IDは変更できません

3. 値オブジェクト(Value Object)- 同一性を持たないオブジェクト

値オブジェクトは属性の値そのもので判断されるオブジェクトです。同一性ではなく、値の等価性が重要です。

サザエさんでの例:

  • 住所:「東京都世田谷区あさひが丘3丁目」という文字列ではなく、構造を持った値として扱う
  • 金額:「500円」という値(通貨と数値をセットで管理)

値オブジェクトは不変(イミュータブル)にするのが原則です。変更する場合は新しいインスタンスを作成します。

@dataclass(frozen=True)
class Address:
    """住所の値オブジェクト(不変)"""
    prefecture: str
    city: str
    town: str
    
    def __post_init__(self):
        if not all([self.prefecture, self.city, self.town]):
            raise ValueError("住所の全ての項目を入力してください")
    
    def full_address(self) -> str:
        return f"{self.prefecture}{self.city}{self.town}"
    
    def __eq__(self, other) -> bool:
        """値の等価性で判定"""
        if not isinstance(other, Address):
            return False
        return (
            self.prefecture == other.prefecture and
            self.city == other.city and
            self.town == other.town
        )


@dataclass(frozen=True)
class Money:
    """金額の値オブジェクト"""
    amount: int
    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 __str__(self):
        return f"¥{self.amount:,}"


# 使用例
isono_house = Address(
    prefecture="東京都",
    city="世田谷区",
    town="あさひが丘3丁目"
)

pocket_money = Money(500)
additional = Money(300)
total = pocket_money.add(additional)  # 新しいインスタンスが返る
print(total)  # ¥800

ポイント解説:

  • @dataclass(frozen=True)で不変性を保証します
  • 値オブジェクトに操作を加える場合は、新しいインスタンスを返します(addメソッド)
  • 住所や金額など、ビジネスルールを持つ概念を値オブジェクトとして表現することで、バリデーションやロジックを一箇所に集約できます

4. 集約(Aggregate)- 一貫性の境界

集約は、関連するエンティティと値オブジェクトをまとめたものです。集約の中には集約ルートと呼ばれる代表エンティティが存在し、外部からのアクセスは必ず集約ルートを経由します。

サザエさんでの例:

  • **磯野家(家族)**が集約、**波平さん(世帯主)**が集約ルート
  • 家族メンバーの追加や変更は、必ず世帯主を通して行う

この設計により、家族内の整合性(例:同じ住所に住んでいる、扶養関係が正しいなど)を保証できます。

from typing import List


class Family:
    """家族集約(波平さんが集約ルート)"""
    
    def __init__(
        self,
        household_head: Person,  # 世帯主(集約ルート)
        family_name: str,
        address: Address
    ):
        self._household_head = household_head
        self._family_name = family_name
        self._address = address
        self._members: List[Person] = [household_head]
    
    @property
    def household_head(self) -> Person:
        return self._household_head
    
    @property
    def family_name(self) -> str:
        return self._family_name
    
    @property
    def address(self) -> Address:
        return self._address
    
    @property
    def members(self) -> List[Person]:
        """メンバーのコピーを返す(直接変更させない)"""
        return self._members.copy()
    
    def add_member(self, person: Person):
        """家族メンバーの追加(集約ルート経由)"""
        if person in self._members:
            raise ValueError("既に家族メンバーです")
        
        # ビジネスルール:同じ姓であること
        if person.full_name.split()[0] != self._family_name:
            raise ValueError(f"姓が{self._family_name}ではありません")
        
        self._members.append(person)
    
    def remove_member(self, person: Person):
        """家族メンバーの削除"""
        if person == self._household_head:
            raise ValueError("世帯主は削除できません")
        
        if person not in self._members:
            raise ValueError("家族メンバーではありません")
        
        self._members.remove(person)
    
    def move_to(self, new_address: Address):
        """引っ越し(家族全員の住所が変わる)"""
        self._address = new_address
    
    def member_count(self) -> int:
        return len(self._members)


# 使用例
namihei = Person(
    person_id=PersonId("P003"),
    family_name="磯野",
    given_name="波平",
    birth_date=date(1895, 1, 1)
)

isono_family = Family(
    household_head=namihei,
    family_name="磯野",
    address=isono_house
)

# カツオくんを追加
isono_family.add_member(katsuo)

print(f"{isono_family.family_name}家は{isono_family.member_count()}人家族です")
# 磯野家は2人家族です

ポイント解説:

  • 外部から家族メンバーを追加・削除する際は、必ずFamilyクラス(集約ルート)のメソッドを経由します
  • これにより、「同じ姓である」「世帯主は削除できない」といったビジネスルールを一箇所で管理できます
  • membersプロパティはコピーを返すことで、外部から直接リストを変更されるのを防ぎます

5. リポジトリ(Repository)- データの永続化を抽象化

リポジトリは、集約の永続化(データベースへの保存・取得)を担当します。ドメインモデルからは、あたかもコレクション(リストのようなもの)にアクセスしているかのように見えます。

サザエさんでの例:

  • 家族台帳にアクセスして、磯野家の情報を取得・保存する
from abc import ABC, abstractmethod
from typing import Optional


class FamilyRepository(ABC):
    """家族リポジトリのインターフェース"""
    
    @abstractmethod
    def save(self, family: Family) -> None:
        """家族情報を保存"""
        pass
    
    @abstractmethod
    def find_by_family_name(self, family_name: str) -> Optional[Family]:
        """姓から家族を検索"""
        pass


class InMemoryFamilyRepository(FamilyRepository):
    """メモリ上に保存する実装(テスト用)"""
    
    def __init__(self):
        self._families: dict[str, Family] = {}
    
    def save(self, family: Family) -> None:
        self._families[family.family_name] = family
        print(f"{family.family_name}家を保存しました")
    
    def find_by_family_name(self, family_name: str) -> Optional[Family]:
        return self._families.get(family_name)


# 使用例
repository = InMemoryFamilyRepository()

# 磯野家を保存
repository.save(isono_family)

# 検索
found_family = repository.find_by_family_name("磯野")
if found_family:
    print(f"見つかりました:{found_family.family_name}家({found_family.member_count()}人)")

ポイント解説:

  • リポジトリは抽象クラス(インターフェース)として定義し、具体的な実装は別途用意します
  • これにより、データベースの種類(MySQL、PostgreSQL、NoSQLなど)を変更しても、ドメインモデル側のコードは変更不要になります
  • テスト時にはメモリ内実装を使い、本番環境では実際のデータベース実装を使うといった切り替えが容易です

6. ドメインサービス(Domain Service)- エンティティに属さない処理

ドメインサービスは、特定のエンティティや値オブジェクトに属さないビジネスロジックを表現します。

サザエさんでの例:

  • 親戚判定サービス:2つの家族が親戚関係にあるかを判定する
  • この処理はどちらか一方の家族だけに属するものではありません
class FamilyRelationService:
    """家族関係を扱うドメインサービス"""
    
    @staticmethod
    def are_related(family1: Family, family2: Family) -> bool:
        """2つの家族が親戚関係にあるかチェック"""
        # 簡易実装:共通のメンバーがいれば親戚とする
        members1 = set(family1.members)
        members2 = set(family2.members)
        return len(members1 & members2) > 0
    
    @staticmethod
    def merge_families(
        family1: Family,
        family2: Family,
        new_family_name: str
    ) -> Family:
        """結婚などで2つの家族を統合"""
        # 新しい家族を作成(簡易実装)
        new_family = Family(
            household_head=family1.household_head,
            family_name=new_family_name,
            address=family1.address
        )
        
        # メンバーを追加(重複チェックは省略)
        for member in family2.members:
            if member != family2.household_head:
                try:
                    new_family.add_member(member)
                except ValueError:
                    pass  # 姓が異なる場合などはスキップ
        
        return new_family


# 使用例
relation_service = FamilyRelationService()

# フグ田家も作成(サザエさんの婚家)
masuo = Person(
    person_id=PersonId("P004"),
    family_name="フグ田",
    given_name="マスオ",
    birth_date=date(1917, 1, 1)
)

fuguta_house = Address(
    prefecture="東京都",
    city="世田谷区",
    town="あさひが丘3丁目"
)

fuguta_family = Family(
    household_head=masuo,
    family_name="フグ田",
    address=fuguta_house
)

fuguta_family.add_member(sazae)

# 親戚判定(サザエさんが両方にいるので親戚)
if relation_service.are_related(isono_family, fuguta_family):
    print("磯野家とフグ田家は親戚関係です")

ポイント解説:

  • ドメインサービスは、複数の集約をまたがる処理や、特定のエンティティに属さないロジックを扱います
  • 状態を持たず、主にメソッドのみで構成されるため、staticmethodとして実装することも多いです
  • 「誰の責任か」が曖昧な処理は、ドメインサービスとして切り出すことを検討しましょう

DDDのメリット:サザエさんの例から学ぶ

1. ビジネスルールの一元管理

家族に関するルール(「同じ姓であること」「世帯主は削除できない」など)をFamilyクラスに集約することで、ルールの変更時も一箇所を修正するだけで済みます。

2. 変更に強い設計

リポジトリパターンにより、データベースを変更してもドメインモデルは影響を受けません。磯野家の家族構成をCSVファイルで管理していても、後からPostgreSQLに移行しても、Familyクラスのコードは変わりません。

3. テストしやすい

値オブジェクトやエンティティは純粋なドメインロジックなので、データベース不要でテストできます。カツオくんを家族に追加する処理をテストする際、実際のデータベースは不要です。

4. ユビキタス言語の確立

開発者と業務担当者が「家族」「世帯主」「集約」といった共通の言語で会話できるようになります。コードにもこれらの用語が反映されるため、仕様とコードの乖離が起きにくくなります。

まとめ:サザエさんとDDDの親和性

サザエさんの世界は、私たちが日常的に理解している概念で構成されています。だからこそ、DDDの抽象的な概念を学ぶ題材として最適です。

本記事で学んだDDDの重要概念:

  1. エンティティ:サザエさんやカツオくんのように、同一性を持つオブジェクト
  2. 値オブジェクト:住所や金額のように、値そのもので判断されるオブジェクト
  3. 集約:磯野家のように、関連オブジェクトをまとめて整合性を保つ境界
  4. リポジトリ:家族台帳のように、データの永続化を抽象化する仕組み
  5. ドメインサービス:親戚判定のように、特定のエンティティに属さない処理

DDDは大規模システムで真価を発揮しますが、小規模なアプリケーションでも「ビジネスロジックをどこに書くべきか」という指針を与えてくれます。

まずは、あなたの担当する業務領域を「サザエさんだったらどう表現するか」と考えてみてください。その思考の過程が、DDDの第一歩となるはずです。

参考資料

  • エリック・エヴァンス『ドメイン駆動設計』(翔泳社)
  • ヴァーン・ヴァーノン『実践ドメイン駆動設計』(翔泳社)

キーワード: ドメイン駆動設計、DDD、Python、サザエさん、エンティティ、値オブジェクト、集約、リポジトリ、ドメインサービス、設計パターン、オブジェクト指向設計

フリーランスボード

20万件以上の案件から、副業に最適なリモート・週3〜の案件を一括検索できるプラットフォーム。プロフィール登録でAIスカウトが自動的にマッチング案件を提案。市場統計や単価相場、エージェントの口コミも無料で閲覧可能なため、本業を続けながら効率的に高単価の副業案件を探せます。フリーランスボード

ITプロパートナーズ

週2〜3日から働ける柔軟な案件が業界トップクラスの豊富さを誇るフリーランスエージェント。エンド直契約のため高単価で、週3日稼働でも十分な報酬を得られます。リモートや時間フレキシブルな案件も多数。スタートアップ・ベンチャー中心で、トレンド技術を使った魅力的な案件が揃っています。専属エージェントが案件紹介から契約交渉までサポート。利用企業2,000社以上の実績。ITプロパートナーズ

Midworks 10,000件以上の案件を保有し、週3日〜・フルリモートなど柔軟な働き方に対応。高単価案件が豊富で、報酬保障制度(60%)や保険料負担(50%)など正社員並みの手厚い福利厚生が特徴。通勤交通費(月3万円)、スキルアップ費用(月1万円)の支給に加え、リロクラブ・freeeが無料利用可能。非公開案件80%以上、支払いサイト20日で安心して稼働できます。Midworks

らくらくPython塾 – 読むだけでマスター