ドメイン駆動設計(DDD)を整理
はじめに
今回は「ドメイン駆動設計入門」という書籍を読み、自分なり理解に落とし込んだので整理をしていきます。
書籍「ドメイン駆動設計」ではC#を用いた例で解説をしていましたが、本記事ではTypeScriptを利用して解説をしていきます。
この記事の主な対象者
- エンジニア初心者から中級者
- ドメイン駆動設計(DDD)の概要を掴みたい人や入門したい人
- TypeScriptを用いてフロントやサーバーの開発をしてる人
お断り
全体概要と用語の整理
まず初めにドメイン駆動設計の全体の概要と出てくる用語について紹介します。
自分は言葉を理解しないとコードの理解に落とし込めなかったので詳しく解説をしていきます。
各用語の具体的な実装は後の章で紹介します。
すべての用語において理解しやすいように「ユーザー管理システムを実装する」例を用いて解説を入れています。(解説の都合で書籍とは異なる例を採用しています)
ドメイン駆動設計とは
- ドメイン駆動設計はその名の通り、「ドメインの知識」に焦点をあてた設計方法
- 「ドメイン」とは、ソフトウェア開発におけるプログラムを適応する対象となる領域
ドメインについて
ドメイン駆動設計における「ドメイン」とは、特定のビジネスにおける「知識」「ルール」「要求」を表す概念です。
ユーザー管理システムを例にDDDにおける「ドメイン」について説明します
ドメイン知識
ユーザー管理システムにおけるドメイン知識は下記の機能から構成されています。
- ユーザー登録
- ログイン
- ログアウト
- パスワード変更
- ユーザー情報の更新
- アカウント削除
など、ユーザー管理システムでにおいて必要な機能がドメイン知識を構成します。
ドメインルール
ユーザー管理システムにおけるドメインルールは下記のようなものが挙げられます
- ユーザーIDは一意でないといけない
- ユーザーのメールアドレスの形式は適切か
- パスワードの最低文字数や特殊文字の使用
など、ドメインが正しく機能する上で守る必要があるルールです。
ドメインの要求
ユーザー管理システムのドメインの要求として下記のようなものがあります。
- ユーザー登録時に確認メールを送る
- ログイン失敗時はエラーメッセージが表示される
など、システムを利用するユーザーに対して価値を提供するために実現すべき要求があります。
上記で紹介した下記の3つを総合してドメインと呼び、DDDのおける重要な役割を担います。
- ドメイン知識
- ドメインルール
- ドメインの要求
ドメインモデルとは
ドメインモデルは先ほど紹介した、ドメインの「知識」「ルール」「要求」を表現するための概念モデル(抽象的な表現手法)です。
ドメインモデルの主要な要素は下記です。
- エンティティ
- 値オブジェクト
- ドメインサービス
こちらも先ほどと同様にユーザー管理システムを例に説明をしていきます。
※ 各々の詳しい説明は後の章でするので、今がざっくり用語の概要だけ掴んでください。
エンティティ
エンティティは一意の識別子(ID)によって識別されるオブジェクトで、属性が変更可能です。
ドメインモデル内での実態を表現し、ビジネスロジックやルールを実行します。
ユーザー管理システムの場合エンティティの例としてユーザーがあります。
- ユーザーはシステム内で一意のIDを持つ
- ユーザーはユーザー名、メールアドレス、パスワードなどの属性を持つ
- ユーザーはIDによって他のユーザーと区別される
値オブジェクト
値オブジェクトはドメインモデルの一部として、その値によって識別され不変性を持つオブジェクトです。
ユーザー管理システムの場合は下記が値オブジェクトとして扱われます。
- メールアドレス
- パスワード
ドメインサービス
ドメインサービスは、エンティティや値オブジェクトに属さないドメインのビジネスロジックを定義します。
ドメインサービスは、異なるエンティティや値オブジェクトの相互採用を調整しそれらに関連するビジネスロジックや制約を適応します。
ユーザー管理システムにおけるドメインサービスの例として下記のビジネスロジックがあります。
- パスワードのハッシュ化
- メールアドレスの確認
パスワードのハッシュ化は、セキュリティーを保護するビジネスロジックですが、パスワード(値オブジェクト)やユーザー(エンティティ)に直接関与しないので、ドメインサービスとして定義されます。
ドメインサービスを定義することで、ドメインモデルが整理され、各エンティティや値オブジェクトは自身の責務に集中することができます。
ドメイン駆動設計のパターン
ドメイン駆動設計で登場するパターンを全体的に紹介します。
後の章でコードを用いて詳しく解説をするので、「ドメイン駆動設計のパターンはこんな感じのものが登場するのか」という理解だけで今は大丈夫です。
- 知識を表現するパターン
- 値オブジェクト
- エンティティ
- ドメインサービス
- アプリケーションを実現するパターン
- リポジトリ
- アプリケーションサービス
- ファクトリ
- 知識を表現する、より発展的なパターン
- 集約
- 仕様
これらの関係性は下記のようになっています。
知識を表現するパターンは先ほど紹介したので、アプリケーションを実現するパターンについて解説を入れてきます。
アプリケーションを実現するパターンは、ドメインモデルとアプリケーションの具体的な実装を結びつける責務があります。
リポジトリ
リポジトリはエンティティの永続化(保存)や検索を行うインターフェースを提供します。
データベースとドメインモデルの間に位置し、データアクセス(DBへの接続)の詳細を隠蔽する役割を担います。
ユーザー管理システムを例としたリポジトリは下記があります。
- ユーザーの検索
- ユーザーの保存
- ユーザーの削除
ユーザー検索では、IDやメールアドレスを元に、データを検索しアプリケーションサービスやドメインサービスが必要なユーザーのエンティティを取得します。
ユーザーの保存では、ユーザーの新規登録や既存ユーザーの更新を行います。これにより、アプリケーションサービスやドメインサービスがユーザーエンティティの変更をデーアストア(DB)に反映できます。
アプリケーションサービス
アプリケーションサービスは、ユースケース(アプリケーションの操作や機能)を実現するためのコーディネーターを担います。
アプリケーションサービスは、ドメインモデル、エンティティ、ドメインサービス、リポジトリを組み合わせて、ユーザーや外部シシテムからの要求に対応する機能を提供します。
ユーザー管理システムにおける、アプリケーションサービスの具体例としては下記が挙げられます。
- ユーザーの検索 (ロジックの呼び出し)
- ユーザーの登録 (ロジックの呼び出し)
- ユーザーの削除 (ロジックの呼び出し)
ユーザーの検索では、アプリケーションサービスが検索条件(ユーザーのIDやメールアドレス)を受け取り、先ほど作成したリポジトリ(DBとの具体的な接続)を介してデータを実際に取得します。
ユーザー登録では、アプケーションサービスがユーザーの入力情報(名前、メールアドレス、パスワード等)を受け取り、適切なエンティティを作成し、リポジトリーを介して実際にユーザー情報をDBに保存します。
少し噛み砕くと、リポジトリ層では具体的なDB接続のロジック(SQLやORM)を作成し、アプリケーションサービス層では作成したリポジトリを実際に使うイメージです。
これにより、アプリケーションサービス層ではデータストアとの詳細な実装と独立させることができ、データストアが変更された場合でも影響を受けづらくなります。
簡単にいうと、アプリケーションサービス層では、具体的なDBからのデータ取得方法などを意識することなくドメインモデルに焦点を当てた実装が可能になります。
ファクトリ
ファクトリは、エンティティや値オブジェクトの生成や再構築を担当するクラス又はメソットです。
ユーザー管理システムを例に同様にファクトリを解説します。
ユーザーを新規作成する際に下記のような制約が存在するとします。
- メールアドレスは一意である必要がある
- メールアドレスは有効な形式である必要がある
- パスワードは最低8文字で大文字と小文字が数字がそれぞれ1文字含まれている必要がある
ファクトリを利用してユーザーエンティティを生成する際に、これらの制約を満たすことを保証させることができます。
- ファクトリはメールアドレスの形式チェックやパスワードの挙動チェックを適用させる
- ファクトリはリポジトリを使用し、メールアドレスが一意であることを保証する
- すべてのビジネスルール(制約)が満たされた場合にエンティティを生成し、アプリケーションサービスやドメインサービスに返す。
アプリケーションサービスは、ファクトリを経由でユーザーエンティティを作成することでオブジェクトの整合性を維持することができる。
ファクトリを使うことで、ビジネスルールや制約が適応された状態でオブジェクトの生成をできる。
知識を表現するパターン
ここから先ほど紹介した用語についてコード例を使いながら詳しく説明をしていきます。
ドメイン駆動設計における、知識を表現する下記の3パターンについて具体的なコードを使って詳しく解説します。
- 値オブジェクト
- エンティティ
- ドメインサービス
値オブジェクト
ドメイン駆動開発(DDD)における値オブジェクトはアプリケーションのドメインモデル内で特定の値を表現するオブジェクトです。
ドメインモデルは、ドメインの「知識」「ルール」「要求」を表現するための概念モデル(抽象的な表現手法)です。
値オブジェクトは下記の3つの性質があります。
- 不変である (Immutability)
- 交換が可能である
- 等価性によって比較される
不変である
不変性とは「一度作成された後は状態の変更ができない」ことを言います。
今回はクラスを用いらないので少しこの部分は若干ニュアンスが変わります(お断りに記載済)
値オブジェクトにおける不変性の具体的なコードを示します。
type Username = {
readonly value: string;
};
// ユーザーの値オブジェクトを作成する関数(ロジック)
// Username型のオブジェクトを生成する
const createUsername = (value: string): Username => {
// ユーザーオブジェクトのバリデーションロジック等
return {
value: value,
};
};
// 不変性を持つ値オブジェクトを生成
const username = createUsername("Alice");
// 'Alice'が出力
console.log(username.value);
// readonlyを付与しているので不変性が保たれる(エラーになる)
username.value = "tom";
-
createUsername
は値オブジェクト生成するためのバリデーションロジックをもつ(ファクトリ)関数 -
createUsername("Alice");
で実際の値オブジェクトが生成される
余談としてクラスを利用した場合はカプセル化等によって完全な不変性を維持することができます。加えて、メソットも定義しやすくなります。
交換が可能である
交換が可能であるとは、同じ値を持つ値オブジェクトは互いに入れ替えても問題がない性質です。
交換の具体例的なコードを示します。
// 権限の型
type Role = {
value: string;
};
// ユーザー権限の値オブジェクトを生成する関数
const createRole = (value: string): Role => {
// バリデーションなどのロジックを記述
return { value };
};
// 同じ権限を2つ定義
const userRole = createRole('User');
const anotherUserRole = createRole('User');
// 以下の2つのオブジェクトは同じ値を持っているため、交換可能です。
console.log(userRole); // { value: 'User' }
console.log(anotherUserRole); // { value: 'User' }
等価性によって比較される
値オブジェクトがその内部の値に基づいて比較される性質です。
値オブジェクトのインスタンスそのものではなく、その内部の値が同じ値であれば等価とみなされます。
等価性の具体的なコードです。
// 2つのユーザーオブジェクトが等価をチェックする関数
const areUsernamesEqual = (username1: Username, username2: Username): boolean => {
return username1.value === username2.value;
};
// 値オブジェクトを3つ生成する
const username1 = createUsername('Alice');
const username2 = createUsername('Bob');
const username3 = createUsername('Alice');
// 値オブジェクトが等価性によって比較される
console.log(areUsernamesEqual(username1, username2)); // false
console.log(areUsernamesEqual(username1, username3)); // true
ふるまいを持つ値オブジェクト
他の性質として、値オブジェクト自体に振る舞いを持たせることもできます。
ユーザー管理において、ユーザー名の表示形式を変更する振る舞いを例に説明します。
type Username = {
value: string;
toDisplayName: () => string;
};
const createUsername = (value: string): Username => {
if (value === '') {
throw new Error('Username cannot be empty');
}
// ユーザーオブジェクトの先頭の文字を大文字に変換する振る舞い
const toDisplayName = (): string => {
return value.charAt(0).toUpperCase() + value.slice(1);
};
return { value, toDisplayName };
};
// 値オブジェクトを生成
const username = createUsername('alice');
// 値オブジェクト自身が独自の振る舞いを呼びだす
console.log(username.toDisplayName()); // "Alice"
値オブジェクトを採用するモチベーション
最後に値オブジェクトを採用するモチベーションをまとめていきます
- 不正な値を存在させない
- 値オブジェクト生成時の制約により不正データが扱われるのを防ぐ
- 誤った代入を防ぐ
- 意図しないデータ型の代入や不正値の代入を防ぐ
- ロジックの散財を防ぐ
- 値オブジェクト毎の独自な振る舞い(ロジック)を持てるのでメンテナンス性が上がる
エンティティ
ドメイン駆動開発におけるエンティティとは、ドメインモデル内で識別子(ID等)によって区別されるオブジェクトで下記の3つの性質を持ちます。
- 可変である
- 同じ属性であっても区別される
- 同一性により区別される
エンティティは識別子(ID等)によって区別され時間とともに属性が変化することが許容されています。一方で先ほど紹介した値オブジェクトは属性によって区別され、不変である必要があります。
可変である
まず識別子(ID)を持ったユーザーのエンティティとユーザーエンティティを実際に生成する関数を作成します。
type User {
id: number;
name: string;
email: string;
}
const createUser = (id: number, name: string, email: string): User => {
if (!id || !name || !email) {
throw new Error('Invalid arguments for creating user');
}
return {
id,
name,
email
};
};
ユーザーの名前を変更する関数を定義します。
// ユーザー名を変更する
const changeUserName = (id: number, name: string, email: string): User => {
if (!newName) {
throw new Error('Invalid argument for changing user name');
}
return {
...user,
name: newName
};
};
// ユーザーのエンティティを生成する
const user = createUser('1', 'Alice', 'alice@example.com');
console.log(user); // { id: '1', name: 'Alice', email: 'alice@example.com' }
// ユーザーのエンティティを更新する
const updatedUser = changeUserName(user, 'Alicia');
console.log(updatedUser); // { id: '1', name: 'Alicia', email: 'alice@example.com' }
上記のように、エンティティは可変性を持つので値を変更することができます。
同じ属性であっても区別される
同じ属性(名前とメールアドレス)を持つユーザーエンティティを作成して比較をします。
const user1 = createUser('1', 'Alice', 'alice@example.com');
const user2 = createUser('2', 'Alice', 'alice@example.com');
これらは識別子(ID)を持っているので、異なるエンティティになります。
同一性により区別される
2つのユーザーエンティティが同じ識別子(ID)を持っているかどうかで判定します。同じ識別子の場合は同一のエンティティであるとみなされます。
const isSameUser = (userA: User, userB: User): boolean => {
return userA.id === userB.id;
};
const user1 = createUser('1', 'Alice', 'alice@example.com');
const user2 = createUser('1', 'Bob', 'bob@example.com');
// user1とuser2は同じエンティティである
console.log(isSameUser(user1, user2)); // true
値オブジェクトとエンティティの判断基準
- そのオブジェクトが時間経過とともに変化する属性を持っていればエンティティとして扱う
- そのオブジェクトが識別子で区別する必要があればエンティティとして扱う
- そのオブジェクトが単なる属性であり、識別子を持つ必要がない場合は値オブジェクトとして扱う
具体的に、値オブジェクトとして扱うものとしては「住所、金額」などがあたり、エンティティとして扱うものとして「ユーザー、商品」などが挙げられる。
エンティティを採用するモチベーション
- 同一性の表現: 識別子(ID)を持つので、同一性を表現できる。
- 状態変更の管理: 可変性を持つので、時間とともに状態を変えることができビジネス要件をより正確に表現できる
- ドメインの抽象化: ドメインモデルをより抽象化し、ビジネスロジックを整理できる
ドメインサービス
ドメインサービスは、値オブジェクトやエンティティが持つべきでない(持つと不自然になる)ロジックを適切に分離し、ドメインオブジェクト間の相互関係を担います。
値オブジェクトやエンティティのバリデーションチェックなど、自身の属性に関するビジネスロジックは値オブジェクトの中に持たせますが、DBに保存するパスワードをハッシュ化する処理などはドメインサービスに持たせることで責務を適切に分離させることができます。
値オブジェクト側で持たせるロジックの具体例
const createEmail = (email: string): Email => {
// メールアドレスが適切な形式化をチェックするロジック等
if (!validateEmail(email)) {
throw new Error("Invalid email format.");
}
return { value: email };
};
ドメインサービスで持たせるロジック
// ユーザーエンティティの型定義
type User = {
id: number;
name: string;
email: string;
password: Password;
};
// ハッシュ化したパスワードの型定義
type Password = {
hashedValue: string;
};
// ドメインサービス内で定義するDB保存に使うハッシュ化関数
const createPassword = (plainTextPassword: string): Password => {
// パスワードハッシュ化処理(実際には安全なハッシュ化アルゴリズムを使用する)
const hashedValue = "hashed_" + plainTextPassword;
return { hashedValue };
};
他にもユーザー管理システムにおいてドメインサービスと定義できるものは下記が挙げられます。
- パスワードリセット
- ユーザーログイン認証
- ユーザーの役割管理(ロール割り当て)
- ユーザー検索
ドメインサービスを採用するモチベーション
- 責務の分離: エンティティや値オブジェクトで持つべきものでないロジックを分離し責務の切り分けができる
- 再利用性: 複数のエンティティや値オブジェクトでドメインサービスを使い回すことができる
- 関心の分離: ドメインサービスはドメイン層に集中させることができ、アプリケーション層やストラクチャ層に関心を分離させることができる
知識を表現するパターンのまとめ
- 値オブジェクト: ドメインモデル内で不変性を持つオブジェクトで属性の値によってのみ識別される
- エンティティ: ドメインモデル内で識別子(ID)を持ち、時間とともに属性が変化する
- ドメインサービス: 値オブジェクトやエンティティによって表現しきれないビジネスロジックを扱う
アプリケーションを実現するパターン
アプリケーションを実現するパターンは、ドメイン知識を活用し、実際のアプリケーション機能を実装する際に用いられるパターンで主に下記の3つの要素で構成されています。
- リポジトリ
- アプリケーションサービス
- ファクトリ
これらを具体的なコードを使って解説をしていきます。
リポジトリ
リポジトリは、データベースとの間でデータの読み書きを行い、ドメインモデルに従ってデータを変換します。
リポジトリはアプリケーションサービスやドメインサービスがデータベースにアクセスする際のインターフェースを提供し、具体的な実装からドメインを分離します。
オブジェクトのインスタンスをデータベースに保存したい時は、直接的にデータベースに書き込むの処理を書くのではなく、リポジトリにインタンスの永続化(保存)を依頼します。
具体的なリポジトリの実装例
ユーザー管理において下記を例にリポジトリの具体的な実装例を解説します。
- ユーザーの一覧取得
- ユーザーの詳細取得
- 保存
ユーザーオブジェクトとリポジトリを抽象化するインターフェースを定義
// ユーザーオブジェクト
type User = {
id: string;
name: string;
email: string;
};
// UserRepositoryは、Userドメインオブジェクトの永続化層へのアクセスを抽象化するインターフェースです。
type UserRepository = {
findById: (id: string) => Promise<User | null>;
findAll: () => Promise<User[]>;
save: (user: User) => Promise<void>;
};
次に具体的なリポジトリの処理を実装します。
今回はDB接続はしないので、インメモリ配列を利用してデータを保存します。
// ユーザー情報を保持するためのインメモリ配列
const users: User[] = [];
// 指定されたIDを持つユーザーを検索し、存在すればユーザーを返し、存在しなければnullを返す
const findById = async (id: string): Promise<User | null> => {
return users.find((user) => user.id === id) || null;
};
// すべてのユーザーを検索し、ユーザーの配列をかえす
const findAll = async (): Promise<User[]> => {
return users;
};
// ユーザーを保存します。ユーザーが既に存在する場合は更新し、存在しない場合は新規追加
const save = async (user: User): Promise<void> => {
const index = users.findIndex((u) => u.id === user.id);
if (index !== -1) {
users[index] = user;
} else {
users.push(user);
}
};
// userRepositoryは、UserRepositoryインターフェースに従ったインメモリリポジトリの実装です。
export const userRepository: UserRepository = {
findById,
findAll,
save,
};
インメモリ配列ではなくPrismaなどを用いた場合は下記のような実装になります。
// 指定されたIDを持つユーザーを検索し、存在すればユーザーを返し、存在しなければnullを返します。
const findById = async (id: string): Promise<User | null> => {
const user = await prisma.user.findUnique({ where: { id } });
return user ? { ...user } : null;
};
// すべてのユーザーを検索し、ユーザーの配列として返します。
const findAll = async (): Promise<User[]> => {
const users = await prisma.user.findMany();
return users.map((user) => ({ ...user }));
};
export const userRepository: UserRepository = {
findById,
findAll,
};
作成したリポジトリをビジネスロジックを実装するサービス層で呼び出す例です。
※ サービス層についてはこの次に説明するので「リポジトリを実際に呼び出している層」と一旦は考えてください。
// ユーザーの作成
const createUserService = async (name: string, email: string): Promise<User> => {
const newUser: User = {
id: generateUniqueId(), // ユニークなIDを生成する関数
name: name,
email: email,
};
// 具体的なDBとの接続処理はリポジトリ内で行われている
await userRepository.save(newUser);
return newUser;
};
// ユーザーの取得
const getUserByIdService = async (id: string): Promise<User | null> => {
// 具体的なDBとの接続処理はリポジトリ内で行われている
return await userRepository.findById(id);
};
// すべてのユーザーの取得
const getAllUsersService = async (): Promise<User[]> => {
// 具体的なDBとの接続処理はリポジトリ内で行われている
return await userRepository.findAll();
};
リポジトリを採用するモチベーション
※ 下記において「永続化」という言葉をわかりやすく「データベースへの保存やアクセス」と置き換えています。
- 永続化層の抽象化: データベースへの保存やアクセスを抽象化できる
- コードの再利用性: データベースへの保存処理等を集約でき複数の場所で再利用しやすくなる
- テストの容易性: データベースへの保存やアクセスを独立化させるので単体テストや結合テストを行いやすい
- 責務の分離: ドメイン層ではビジネスロジックやドメインモデル、リポジトリではデータベースへの保存やアクセスと責務を明確に分離できる
アプリケーションサービス
アプリケーションサービスは、ユースケースを実現する役割を持っています。ユースケースはシステムが提供すべき機能やタスクをユーザーの視点から表現したものです。
またドメイン層(エンティティや値オブジェクト)とインフラストラクチャ層(リポジトリ)の間に位置し、ドメインロジックを実行します。
ユーザー管理システムにおけるユースケースの例として下記が挙げられます
- ユーザーの登録
- ユーザー一覧・詳細の取得
- ユーザーの更新
- ユーザーの削除
- ユーザーの検索
リポジトリを呼び出す場合
具体的なアプリケーションサービスのコード例です。リポジトリ層を呼び出しています。
// ユーザー新規作成用のサービス
const createUserService = async (name: string, email: string): Promise<User> => {
const newUser: User = {
id: generateUniqueId(), // ユニークなIDを生成する関数
name: name,
email: email,
};
// 具体的なDBとの接続処理はリポジトリ内で行われている
await userRepository.save(newUser);
return newUser;
};
// ユーザーの取得
const getUserByIdService = async (id: string): Promise<User | null> => {
// 具体的なDBとの接続処理はリポジトリ内で行われている
return await userRepository.findById(id);
};
// すべてのユーザーの取得
const getAllUsersService = async (): Promise<User[]> => {
// 具体的なDBとの接続処理はリポジトリ内で行われている
return await userRepository.findAll();
};
アプリケーションサービスを使わない場合
もしアプリケーションサービスを利用しなかったらどのような影響を及ぼすのかを具体的なコード例とともに解説をします。
ユーザー登録を行うpage(UI)
import React from 'react';
import { UserRepositoryImpl } from '../repository/userRepositoryImpl';
import { User } from '../domain/user';
export const UserRegistration: React.FC = () => {
// ユーザーリポジトリをインスタンス化
const userRepository = new UserRepositoryImpl();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const email = event.currentTarget.email.value;
const password = event.currentTarget.password.value;
// ユーザーオブジェクトの生成 (本来サービス層が責務を持つ)
const user = User.create({ email, password });
// ユーザーの保存 (本来サービス層が責務を持つ)
await userRepository.save(user);
};
return (
<form onSubmit={handleSubmit}>
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit">登録</button>
</form>
);
};
上記のコードはアプリケーション層がないので、ドメインロジック(ユーザーオブジェクトの生成)とリポジトリへのアクセスをUIコンポーネント内で直接実行している。
これによって下記のような問題が生じる可能性があります。
- ドメインロジックとUIの連携が密結合になり、変更や再利用が難しい
- トランザクションの管理がUIコンポーネントに散らばり、データの整合性が維持できない
- UIテストとドメインロジックのテストが一緒に行われる
ドメインサービス層とアプリケーションサービス層の違い
層 | 責務 |
---|---|
ドメインサービス | 1. 複数のエンティティや値オブジェクトをまたいだドメインロジックの実行 |
2. 外部サービスとの連携 | |
アプリケーションサービス | 1. ユースケースの実現 |
2. トランザクション管理 | |
3. ドメインサービスやリポジトリの調整 |
アプリケーションサービスを採用するモチベーション
- ユースケースの実現: ドメインロジックとUIや外部システムの連携を分離しそれぞれの責務を明確にする
- トランザクションの管理: データの整合性を保つ
- ドメインサービスやリポジトリの調整: ビジネスロジックを実行する役割を持ち、コードの可読性や保守性を上げられる
- コードの再利用性: 共通のユースケースを利用しやすくなる
ファクトリ
DDDにおいてファクトリはエンティティや値オブジェクトの生成や再構築を行う責務を持っています。
ユーザー管理システムにおいて、ユーザーエンティティを新規作成する例をもとに説明します。
ユーザーIDとユーザーエンティティを定義
// ユーザーIDのオブジェクト
export type UserId = {
value: string;
}
// User.ts(ユーザーエンティティ)
export type User = {
id: UserId;
name: string;
email: string;
}
export const createUser = (id: UserId, name: string, email: string): User => ({
id,
name,
email,
});
ユーザーファクトリを作成
// UserFactory.ts
import { User, createUser } from './User';
import { UserId, createUserId } from './UserId';
export const UserFactory = {
// ユーザーを新規作成する際に使用する
create: (name: string, email: string): User => {
// IDを自動生成する処理
const id = createUserId(generateUniqueId());
return createUser(id, name, email);
},
};
ファクトリを呼び出し、ユーザーエンティティを作成
import { UserFactory } from './UserFactory';
// 新規ユーザーを作成
const newUser = UserFactory.create('John Doe', 'john.doe@example.com');
console.log('Created new user:', newUser);
今紹介した例では「ファクトリを生成せずにエンティティに定義しているcreateUserを直接呼び出した方がよくない?」と思うかもしれません。
今回は説明のために簡単な例を紹介しましたが、複雑なオブジェクト生成が必要な場合は、ファクトリ経由でオブジェクトを生成した方がより生成プロセスを効率的に管理することができます。
ファクトリを採用するモチベーション
- 複雑なオブジェクト生成時にロジックが肥大化するのを防ぐ
- 最高性の一貫性を維持できる
- テストを容易化できる
ファクトリの生成は必ずしも必要なことではないので、エンティティの生成ロジックが比較的単純な場いいは必要ありません。
データの整合性を保つ
整合性とは、「矛盾なく一貫性のあること・ズレがないこと」を言います。
例えばユーザー管理システムにおいては、複数のデータを一括して処理する場合にトランザクションを利用することで、途中でエラーが発生しても処理をロールバックし、データの整合性を保つことができます。
ドメイン駆動設計の整理
最後にこれまで紹介してきた例を整理していきます。
前提でも記載しましたがクラスを利用していないので「ドメイン風味」な設計としてまとめていきます。
またイメージがつきやすいようにかなり簡易的な例で紹介しています。
- 値オブジェクトの作成 : メールアドレス・パスワード
- エンティティの作成: ユーザー
- ドメインサービス: パスワードのハッシュ化
- リポジトリ: ユーザーの保存と取得
- アプリケーションサービス: ユーザー作成
- ファクトリ: ユーザーオブジェクトの作成
1. 値オブジェクトの作成 : メールアドレス・パスワード
値オブジェクトであるメールアドレスとパスワードを作成します。
const createEmailAddress = (email) => {
// isValidEmailはメールアドレスが有効かをチェックする
if (!isValidEmail(email)) {
throw new Error('Invalid email address');
}
return email;
};
const createPassword = (password) => {
if (!isValidPassword(password)) {
throw new Error('Invalid password');
}
return password;
};
2. エンティティの作成: ユーザー
ユーザーエンティティを作成します。
const createUser = (id, email, password) => {
return {
id,
email: createEmailAddress(email),
password: createPassword(password),
};
};
3. ドメインサービス: パスワードのハッシュ化
値オブジェクトやエンティティの責務外である、パスワードのハッシュ化のロジックを作成します。
const hashPassword = async (password) => {
// ハッシュ化ロジック
};
4. リポジトリ: ユーザーの保存と取得
DB接続を行いユーザーの新規登録と取得を行います。
const userRepository = {
save: async (user) => {
// ユーザー保存ロジック
},
findById: async (id) => {
// ユーザー取得ロジック
},
};
5. アプリケーションサービス: ユーザー作成
リポジトリ層で作成したuserRepository
を実際に呼び出します。
その際、ドメインサービス層で作成したパスワードをハッシュ化する処理も呼び出します。
const createUserApplicationService = async (email, password) => {
// DBに保存するパスワードはハッシュ化させる
const hashedPassword = await hashPassword(password);
// ユーザーオブジェクトを作成する
const user = createUser(generateUserId(), email, hashedPassword);
// リポジトリで実際に呼び出す
await userRepository.save(user);
};
6. ファクトリ: ユーザーオブジェクトの作成
const userFactory = (id, email, password) => {
return createUser(id, email, password);
};
こちらを利用してアプリケーションサービスにおけるエンティティの生成をファクトリに置き換えてっます。
const createUserApplicationService = async (email, password) => {
// DBに保存するパスワードはハッシュ化させる
const hashedPassword = await hashPassword(password);
// ユーザーエンティティをファクトリを使って生成
const user = userFactory(email, hashedPassword);
// リポジトリで実際に呼び出す
await userRepository.save(user);
};
なおエンティティ生成時に必ずしもファクトリ経由にする必要はありません。
ファクトリにすべき適切な例として下記が挙げられます
- オブジェクトの生成プロセスが複雑な場合
- オブジェクトの生成プロセスが異なる複数のバリエーションが存在する場合
- オブジェクト生成時に、生成プロセスに対して共通のロジック(例えば、IDの生成やバリデーション)が存在する場合。
例のコードでは、userFactory
を使うことでユーザー生成に必要なID生成やメールアドレスのバリデーション、パスワードのハッシュ化などの共通ロジックを一箇所にまとめることができます。
またクライアントコードでは、userFactory
を使用して簡単にユーザーオブジェクトを生成できるようになります。
参考文献
本記事は「ドメイン駆動設計入門」を参考にしています。
最後に
今回はドメイン駆動(風味な)設計について解説をしました。
他にも色々な記事を出しているので合わせて読んでいただけると嬉しいです。
Discussion