ドメイン駆動設計入門(DDD)を読んでみて
はじめに
こんにちは。スペースマーケットでWebエンジニアしてるmotimoti63です。
最近、バックエンドのプロジェクトでDDD(ドメイン駆動設計)の思想に基づいたアーキテクチャを採用しているレポジトリを触る機会がありました。実際にコードを書いていく中で「これ、どういう意図で設計されてるんだろう?」と戸惑うことが多々ありました。
「まずはDDDって何?」というところからしっかり理解したいと思い、初心者にもわかりやすい本を見つけたので、それを参考にしながら学習を進めてみました。
今回はその内容を、自分なりにかみ砕いてまとめた記事になります。まだまだ理解が浅い部分もあるかもしれませんが、その点はご容赦いただければと思います。
書籍
ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本
ドメイン駆動設計とは
DDD(ドメイン駆動設計)は、単なる設計手法ではなく、業務の複雑さに真正面から向き合い、それを整理していくための「考え方」や「道具箱」のようなものです。
もちろん、ソフトウェアの初期段階から一定の効果はありますが、DDDの真価はプロジェクトが成長し、変化や複雑さが増したときにこそ発揮されます。導入当初はあまり効果を実感できないかもしれませんが、プロジェクトの規模が大きくなるにつれて、各レイヤーの責務やデータの整合性をどう保つかが重要になってきます。
DDDは、そうした課題を整理し、適切に分離して扱うための指針を与えてくれる設計アプローチです。
ドメインとは
具体例でいうと
銀行システムのドメイン → 「金融・口座管理・振込などの業務」
ECサイトのドメイン → 「商品管理・カート・注文・決済など」
スペースマーケットのドメイン → 「スペースの貸し借りにまつわる予約・契約・利用など」
それではさっそく、DDDの主要なコンセプトを順に見ていきましょう。
値オブジェクト
値オブジェクトは、ドメインオブジェクトの基本的な構成要素のひとつです。
その概念はとてもシンプルで、システムに登場する「金額」や「単価」などの値を、ただの数値ではなく意味を持ったオブジェクトとして扱うという考え方です。
なぜ、この考え方が大事かを以下の①から⑤の実装の例を見ながら考えていきましょう。
(書籍ではC#で書かれてますが僕が理解しやすいようにPythonで書き換えてます🙇♂️)
①プリミティブな値で「氏名」を表現する
full_name = "naruse masanobu"
print(full_name) # naruse masanobu
ここに何の変哲もない名前が定義されている。
②氏名から姓を表示する
full_name = "naruse masanobu"
tokens = full_name.split(" ") # ["naruse", "masanobu"]
last_name = tokens[0]
print(last_name) # naruse
この名前から姓だけを取得したい。その時にsplitを使えばいい!!
・・・・・果たして本当だろうか??
③うまく姓を表示できないパターン
full_name = "john smith"
tokens = full_name.split(" ") # ["john", "smith"]
last_name = tokens[0]
print(last_name) # john
このように日本では通用するが世界には姓と名の順序が逆の文化もあるため、Splitでは不完全である😢
④氏名を表現する FullName クラス
class FullName:
def __init__(self, first_name: str, last_name: str):
self.first_name = first_name
self.last_name = last_name
FullName クラスはコンストラクタで第1引数に名、第2引数に姓を指定するようになっているので、そのルールさえ守られれば、たとえ姓と名の順序が異なるような氏名であっても姓を取り出すことが可能!!
⑤確実に姓を表示できる
name = FullName("john", "smith")
print(name.last_name) # smith
name = FullName("masanobu", "naruse")
print(name.last_name) # naruse
この例からわかることは、システムに最適な値が必ずしもプリミティブな値とは限らない。
FullName クラスはコンストラクタで第1引数に名、第2引数に姓を指定するようになっているので、そのルールさえ守られれば、たとえ姓と名の順序が異なるような氏名であっても姓を取り出すことが可能!!
値オブジェクトを使うモチベーションとしては、主に以下の4つが挙げられます。
表現力を増す
工業製品の製品番号やロット番号などは、アルファベットや記号を含む文字列で構成されることが多く、単なる string 型ではその意味がわかりません。
値オブジェクトを使えば「これは製品番号だ」と明確に示すことができ、コードの可読性と意図の伝達力が向上します。
例えばですが、↓こんな感じ。
ダメな例
product_code = "ABC-12345-XY"
print(product_code)
これだと、ただの文字列なので製品番号とはわかりにくい。コードとして無愛想すぎる。
もっと親切で分かりやすいコードを書くなら?↓
イイ例
import re
class ProductCode:
def __init__(self, code: str):
# 製品コードは「ABC-12345-XY」の形式
pattern = r"^[A-Z]{3}-[0-9]{5}-[A-Z]{2}$"
if not re.match(pattern, code):
raise ValueError("製品番号の形式が不正です")
# コードをパーツに分割して意味を持たせる
self.prefix, self.serial, self.suffix = code.split("-")
self.code = code
def __str__(self):
return self.code
# 正しい形式の製品番号を扱う
pc = ProductCode("ABC-12345-XY")
print(pc) # ABC-12345-XY
print(pc.prefix) # ABC → 製品カテゴリコード
print(pc.serial) # 12345 → シリアル番号
print(pc.suffix) # XY → 製造工場コード
こうすることでちゃんと製品番号一つにしてもいろんな役割があることが分かる。
不正な値を存在させない
例えば「ユーザ名は3文字以上」というルールがあるのに、単に string 型で受け取ってしまうと、2文字でも通ってしまいます。
値オブジェクトのコンストラクタでルールを強制すれば、不正な値が存在できない設計にできます。
コードで書くとこんな感じ↓
ダメな例
username = "Al"
print(username) # Al
当たり前だがこんな雑なコードだと2文字でも問題なく通ってしまう。よろしくない。。。。
イイ例
class UserName:
def __init__(self, name: str):
# ユーザ名は3文字以上とするルールをチェック
if len(name) < 3:
raise ValueError("ユーザ名は3文字以上必要です")
self.value = name
# 有効なユーザ名の場合
user = UserName("Tom")
print(user.value) # Tom
# 無効なユーザ名の場合(エラーが発生)
try:
invalid = UserName("Al")
except ValueError as e:
print(type(e).__name__, e) # ValueErrorとちゃんエラーになって通せん坊してくれる
こう実装することで、より厳密に値を保持できる。
誤った代入を防ぐ
ユーザIDとユーザ名を両方 string 型で扱うと、間違えて代入してもコンパイルエラーになりません。
値オブジェクトを使えば、たとえば UserId と UserName で型が異なるため、誤代入を防ぐことができます。
例はこちら↓
ダメな例
user_id = "123"
user_name = "Alice"
user_id = user_name
print(user_id) # Alice
こちらも当たり前だが誤ってユーザ名をユーザIDに代入してもエラーにならない。よくない。。。。
イイ例
from typing import NewType
UserId = NewType('UserId', str)
UserName = NewType('UserName', str)
uid: UserId = UserId("123")
uname: UserName = UserName("Alice")
uid = uname # error: Incompatible types in assignment (expression has type "UserName", variable has type "UserId")
このように値オブジェクトと型ヒントを使用して実装することで、errorを出しつつバリデートもしてくれてる。
ロジックの散在を防ぐ
ユーザ名の文字数チェックなど、入力値に関するロジックが各所にバラバラに書かれていると保守が困難になります。
値オブジェクトにロジックを集約すれば、重複や漏れを防ぎ変更にも強くなります(DRY原則)[1]。
ダメな例
## 登録処理
def register_user(name: str):
if len(name) < 3:
raise ValueError("ユーザ名は3文字以上必要です")
# 登録処理...
## 更新処理
def update_profile(name: str):
if len(name) < 3:
raise ValueError("ユーザ名は3文字以上必要です")
# 更新処理...
同じ処理なのにまとめられてない。。。
(僕も業務でこのような無駄な実装をしてしまうので同じような処理はまとめて一元管理するように心がけたい。)
イイ例
class UserName:
def __init__(self, name: str):
if len(name) < 3:
raise ValueError("ユーザ名は3文字以上必要です")
self.value = name
def register_user(username: UserName):
# 登録処理...
def update_profile(username: UserName):
# 更新処理...
このように同じ処理はひとまとめてにして管理すると後の変更にも一回の改修で済む。
(例:金額(Money)、日時(DateRange)、メールアドレス(Email)、ユーザー名(UserName))
エンティティ
エンティティは、値オブジェクトと対を成す重要なドメインオブジェクトの一種です。
例えば、データベースのテーブル設計で登場する ER 図にも「エンティティ」がありますが、DDD におけるエンティティはそれとは少し異なり、「同一性(Identity)」に着目した設計概念です。
エンティティには、以下のような特徴があります
可変である
エンティティは属性の変更が許容されるオブジェクトです。
たとえば、ユーザーの名前は人生ではそう頻繁に変わりませんが、システム上ではニックネームの変更などが日常的に発生します。こうした「状態の変化」が前提にあるのがエンティティの特徴です。
例
class User:
def __init__(self, user_id: int, name: str):
self.id = user_id
self.name = name
u = User(1, "Charlie")
print(u.name) # Charlie
u.name = "Chuck" # 名前変更
print(u.name) # Chuck
同じ属性であっても別物として扱われる
エンティティは、たとえ属性の値が同じでも異なる存在として区別されます。
例えば「名前が同じ」ユーザーでも、それぞれが別の人物(エンティティ)である可能性があるため、エンティティ同士は属性値ではなくID(同一性)で識別される必要があります。
例
u1 = User(1, "Dave")
u2 = User(2, "Dave")
print(u1.id, u1.name) # 1 Dave
print(u2.id, u2.name) # 2 Dave
同一性により区別される
エンティティは、同一性(Identity)によって区別される存在です。
ユーザー名を変更しても、そのユーザーが同一人物であると判断するには識別子(ID)が必要です。システム上では、IDを使って「変更前と同じユーザーである」と認識できるようにします。
ダメな例
class UserWithoutId:
def __init__(self, name: str):
self.name = name
u1 = UserWithoutId("Alice")
u1.name = "Alicia"
print(u1.name) # Alicia
このコードだと、もしも名前変更した場合どのAliceなのか判断できない。
イイ例
class UserWithId:
def __init__(self, user_id: int, name: str):
if len(name) < 3:
raise ValueError("ユーザ名は3文字以上必要です")
self.id = user_id
self.name = name
u2 = UserWithId(1, "Alice")
u2.name = "Alicia"
print(u2.id) # 1
print(u2.name) # Alicia
こうすることで名前変更後もIDで同一人物と判断できる。
(例:ユーザー(User)、スペース(Space)、予約(Reservation))
ドメインサービス
ドメインサービスは「不自然さ」を解消する役割をもつ。
値オブジェクトやエンティティでは表現しづらい「ふるまい」や処理を、無理にオブジェクトへ押し込めると責務が歪むことがあります。
そのような場合、そのふるまいを切り出して別の役割として定義するのがドメインサービスです。ドメインサービスは、モデルの自然な設計を保つための手段です。
簡単に言うとドメインサービスは、値オブジェクトやエンティティに含めにくい処理(例:重複チェック)を定義するオブジェクトです。
ダメな例
from typing import List
class User:
def __init__(self, user_id: str, name: str):
self.id = user_id
self.name = name
def exists(self, users: List['User']) -> bool:
return any(u.id == self.id for u in users)
all_users = [User("1", "Alice"), User("2", "Bob")]
user = User("2", "Robert")
print(user.exists(all_users)) # True
このようにエンティティに実装しちゃうと自分自身に重複チェックをすることになるので不自然。🧐
イイ例
from typing import List
class User:
def __init__(self, user_id: str, name: str):
if not user_id or not name:
raise ValueError("IDと名前は必須です")
self.id = user_id
self.name = name
class UserService:
def __init__(self, existing_users: List[User]):
self.existing = existing_users
def exists(self, user: User) -> bool:
return any(u.id == user.id for u in self.existing)
all_users = [User("1", "Alice"), User("2", "Bob")]
service = UserService(all_users)
new_user = User("2", "Robert")
print(service.exists(new_user)) # True (ID 2 が既存)
このようにドメインサービス(UserService)を用意することで不自然さを解消できる。
(例:「重複チェック」「料金計算」「空き状況の判定」「キャンセルポリシー適用」)
リポジトリ
リポジトリは、データの永続化や取得といった処理をアプリケーションのコードから切り離して抽象化する役割を担います。
これにより、データストアに依存しない柔軟な設計が可能になり、将来的な変更やテストの容易さにも貢献します。
アプリケーションは「何を保存するか」は意識しても、「どのように保存するか」は気にせずに済むようにします。
図の引用元
(例:ReservationRepository、UserRepository)
アプリケーションサービス
アプリケーションサービスは、ユースケースの実現を目的とした調整役です。
ドメインオブジェクト(エンティティや値オブジェクト)を組み合わせて問題を解決するように、タスクの進行や処理の流れを管理します。
簡単に言うと、「登録する・情報を取得する・情報を更新する・退会する」といったCRUD処理を担当する場所がアプリケーションサービスだと理解していいと思います。
ここでドメインサービスとの違いは?に疑問を抱く方もいるかもしれません。
こいう風に考えると理解しやすいと思います。ドメインサービスでデータに関しての整合性を監視する。アプリケーションではユースケースを考えその中で複数のドメインサービスやリポジトリを呼び出して利用する。
ここでも注意して欲しいのが、リポジトリの章でも記載した通り重複チェックの処理はドメインサービスが担当するということです。
(例:「予約を作成する」「予約をキャンセルする」「ユーザー登録する」)
集約
集約は、ドメインの整合性を保つために密接に関係するオブジェクトをまとめた構造であり、データの一貫性を保つための「整合性の単位」として扱われます。すべての操作は「集約ルート」と呼ばれるオブジェクトを通じて行われ、外部からの直接操作を防ぎます。
うん?どいうこと?と思われる方が大半だと思います。
実は「集約」は既に登場していて、Userといったオブジェクトが該当します。
こうしたオブジェクトのグループには維持されるべき不変条件[2]が存在します。つまり絶対に変化してはならないということ。
集約はデータの変更の単位であるため、トランザクションやロックとも密接に関係するものです。
ここで僕が混乱したのは集約とトランザクションの違いです。
こちらの資料を参考に以下のように整理してみました。
(例:「カート + 商品一覧」「ユーザー + 支払い情報」「予約 + オプション + 請求情報」)
アーキテクチャ
アーキテクチャとは「知識を記述すべき箇所を示す方針」です。
代表的な構造として以下のアーキテクチャが挙げられます。
⚪︎レイヤードアーキテクチャ
⚪︎ヘキサゴナルアーキテクチャ
⚪︎クリーンアーキテクチャ
今回はこの中でも クリーンアーキテクチャ に焦点をあて、とくにその中核にある 「依存関係逆転の原則(DIP: Dependency Inversion Principle)」 について深掘りしていきます。
クリーンアーキテクチャとは
クリーンアーキテクチャは4つの同心円が特徴的な図によって説明されるアーキテクチャです。
ユーザーインターフェースやデータストアなどの詳細を端に追いやり、依存の方向を内側に向けることで、詳細が抽象に依存するという依存関係逆転の原則を達成します。
別の表現で簡単に一言でいうと、
だと考えます。
外部のフレームワークやデータベースといった「技術的な詳細」を最外層へ追いやり、一番大切なビジネスロジックを中心に配置します。
依存方向が肝となり、ビジネスロジックは技術的な詳細に依存してはいけません。
外側から中心という一方向に依存するようにコントロールすることで、変更に強くテストも容易な持続可能なソフトウェア開発が可能になります。
UseCaseとRepositoryの関係における注意点
クリーンアーキテクチャでは、UseCase(ユースケース)とRepository(永続化の仕組み)は密接な依存関係になってはいけない、というルールがあります。
この2つをつなぐには、必ず「インターフェース」という抽象レイヤを挟むことが推奨されています。
具体的には、UseCaseは「IUserRepository」のようなインターフェースに依存し、その実体(インフラ層にあるDBアクセスの具体クラスなど)は外側のレイヤで注入されます。
これは、「内側のユースケースが外側の技術詳細に依存しないようにする」ためです。
なぜ「依存関係逆転の原則」が重要なのか?
通常の設計では、「使う側が使われる側(詳細)に依存」してしまいがちです。
例えば、UseCaseがMySQLUserRepositoryのような具象クラスに直接依存してしまうと、テストも再利用も難しくなり、将来的な変更にも弱くなります。
しかし、クリーンアーキテクチャにおける依存関係逆転の原則では、
という構造を徹底することで、ビジネスルールを柔軟かつテスタブルに保つことができます。
ちなみにこちらの記事も参考になったのでおすすめです。インタフェースを利用することで使われる側の影響を受けにくくするという点にすごく感動しました。
最後に
まだまだDDD(ドメイン駆動設計)についての理解は浅い部分もありますが、今後もこのアーキテクチャを採用したプロジェクトに積極的に関わりながら、学びを深めていきたいと考えています。
特に、どれだけ抽象的に物事を捉えられるかが、DDDを実践する上で重要な鍵になると感じました。そして何より、「ドメインを独立させること」の大切さを改めて実感しています。
その場しのぎの実装ではなく、将来的な安定性や拡張性を見据えた設計を意識していきたいと思います。今回学んだ「依存しない関係」や「クリーンアーキテクチャ」といった考え方についても、さらに深掘りし、理解を深めていくつもりです。
また、今回の学習を通じて初めて「集約」という概念を知り、トランザクションとの違いについても理解を深めることができました。これまでの自分の知識と書籍で得た新たな視点を照らし合わせる中で、疑問が生まれたり、理解が深まったりと、非常に有意義な読書体験となりました。
ここまで学習した内容をすぐにコードに落とし込むのは簡単ではありませんが、少なくともこの記事で整理したポイントについては、日々の開発で意識的に取り入れていきたいと思います。

スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion