TypeScriptで実践するドメイン駆動設計(DDD)
初めに
モチベーション
私はこれまでドメイン駆動設計で設計されたシステムの開発案件に参画した経験はありますが、1からドメイン駆動設計で設計での設計を行った経験がありませんでした。そのため個人でシステムを開発している際に設計に悩むことがあり、自身で1からドメイン駆動設計ベースの設計ができるようになることを目指して学習を進め、その過程で得た知見をまとめています。
特に以下のような点に焦点を当てています。
- ドメイン駆動設計の理論をTypeScriptのコードベースで理解する
- 既存のドメイン駆動設計システムを理解するだけでなく、新規設計ができるようになるための知識の整理
ドメイン駆動設計(DDD)とは
ドメインとは「システムが対象とする業務領域」のことです。
例えば不動産管理システムを例にすると、物件、オーナー、入居者などがドメインとなります。
ドメイン駆動設計の本質は、このような現場の業務知識やルールを深く理解し、それをプログラムの設計や実装に直接反映させていく手法です。これによって、実際のビジネスの仕組みやプロセスが自然とコードの中に表現され、より実用的で柔軟性のあるシステムを作り出すことができます。
ここからは実際にドメイン駆動設計に登場する代表的なパターンを解説します。
実装パターン
値オブジェクト
値オブジェクトを利用する理由
- ドメインの意図を明確に表現できる
- 不変性による安全性の担保
- ロジックをまとめられる
イミュータブル(不変)であるべき
// 悪い例
class EmailAddress {
private email: string;
constructor(email: string) {
this.email = email;
}
changeEmail(newEmail: string) { // ミュータブル(可変)な実装
this.email = newEmail;
}
}
// 良い例
class EmailAddress {
private readonly email: string;
constructor(email: string) {
this.email = email;
}
// 値を変更するメソッドは実装しない
}
メリット
- 予期せぬ副作用を防ぐ
- キャッシュが容易
等価性によって比較される
class FullName {
constructor(
private readonly firstName: string,
private readonly lastName: string
) {}
equals(other: FullName): boolean {
return this.firstName === other.firstName &&
this.lastName === other.lastName;
}
}
const name1 = new FullName("太郎", "山田");
const name2 = new FullName("太郎", "山田");
const name3 = new FullName("花子", "山田");
console.log(name1.equals(name2)); // true
console.log(name1.equals(name3)); // false
メリット
- 例えば、ミドルネームの属性が追加された場合に値オブジェクトが比較用のメソッドを提供していないがければ、フルネームの属性も比較に追加するように全ての箇所を変更しなければならなりません。上記のコード例の場合はequalsメソッドの修正のみで済みます。
エンティティ
可変(ミュータブル)である
エンティティは値オブジェクトと異なり、ライフサイクルの中で状態が変化することを許容します。
class User {
private readonly id: string;
private name: string;
constructor(
id: string,
name: string,
) {
this.id = id;
this.name = name;
}
// 状態を変更するメソッド
changeName(newName: string): void {
this.name = newName;
}
}
同じ属性であっても区別される
class User {
private readonly id: string;
private name: string;
private email: string;
constructor(id: string, name: string, email: string) {
this.id = id;
this.name = name;
this.email = email;
}
equals(other: User): boolean {
return this.id === other.id; // IDのみで同一性を判断
}
}
// 同じ属性値を持つ2つのユーザー
const user1 = new User("1", "山田太郎", "taro@example.com");
const user2 = new User("2", "山田太郎", "taro@example.com");
const user3 = new User("1", "山田花子", "hanako@example.com");
console.log(user1.equals(user2)); // false - 同じ属性値でもIDが異なれば別物
console.log(user1.equals(user3)); // true - 属性値が異なってもIDが同じなら同一
エンティティは、たとえ全く同じ属性値を持っていても、別々のものとして区別されます。上記の例では、user1とuser2は同じ名前とメールアドレスを持っていますが、IDが異なるため別々のユーザーとして扱われます。
これは値オブジェクトとは対照的な特徴です。値オブジェクトの場合、全ての属性値が同じであれば同一とみなされますが、エンティティはそうではありません。
ドメインサービス
ドメインサービスは、特定のエンティティや値オブジェクトに属さない振る舞いを定義するために使用されます。
ドメインサービスを使用する状況
- 複数のエンティティ/値オブジェクト間の協調動作が必要な場合
- ステートレスな操作を行う場合
- ドメインのビジネスルールを表現する場合
// 悪い例:ユーザーエンティティに重複チェックロジックを入れる
class User {
private readonly id: string;
private email: string;
async checkDuplicateEmail(userRepository: UserRepository): Promise<boolean> {
// このロジックはUserエンティティの責務ではない
const existingUser = await userRepository.findByEmail(this.email);
return existingUser !== null;
}
}
// 良い例:ドメインサービスとして実装
class UserDomainService {
constructor(private readonly userRepository: UserRepository) {}
async isEmailDuplicated(email: string): Promise<boolean> {
const existingUser = await this.userRepository.findByEmail(email);
return existingUser !== null;
}
}
// 使用例
class UserApplicationService {
constructor(
private readonly userDomainService: UserDomainService,
private readonly userRepository: UserRepository
) {}
async register(email: string): Promise<void> {
// メールアドレスの重複チェック
const isDuplicated = await this.userDomainService.isEmailDuplicated(command.email);
if (isDuplicated) {
throw new Error('メールアドレスが既に使用されています');
}
// ユーザー登録処理
const user = new User(generateUserId(), email);
await this.userRepository.save(user);
}
}
リポジトリ
リポジトリは、エンティティの永続化と検索のためのインターフェースを提供するパターンです。データベースなどの技術的な実装の詳細を隠蔽し、ドメインモデルに集中できるようにします。
リポジトリパターンの重要な特徴として、具体的な永続化技術(MySQLやインメモリストレージなど)に依存しない設計が可能になります。これは以下の利点をもたらします。
- インターフェースを通じた抽象化により、実装の詳細を気にせずにドメインロジックを記述できます
- 永続化技術の変更(例:MySQLからMongoDBへの移行)が必要になった場合も、ドメインロジックを変更する必要がありません
- テスト時にはインメモリ実装を使用し、本番環境ではデータベース実装を使用するといった柔軟な切り替えが可能です
以下の例ではUserApplicationService は具体的な実装(InMemoryUserRepository や MySQLUserRepository)ではなく、抽象的なインターフェース(UserRepository)に依存しています。
// リポジトリのインターフェース定義
interface UserRepository {
save(user: User): Promise<void>;
delete(id: string): Promise<void>;
}
// インメモリ実装(テスト用)
class InMemoryUserRepository implements UserRepository {
private users: Map<string, User> = new Map();
async save(user: User): Promise<void> {
this.users.set(user.getId(), user);
}
async delete(id: string): Promise<void> {
this.users.delete(id);
}
}
// データベース実装
class MySQLUserRepository implements UserRepository {
constructor(private readonly connection: MySQLConnection) {}
async save(user: User): Promise<void> {
await this.connection.query(
'INSERT INTO users (id, name, email) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE name = ?, email = ?',
[user.getId(), user.getName(), user.getEmail(), user.getName(), user.getEmail()]
);
}
async delete(id: string): Promise<void> {
await this.connection.query(
'DELETE FROM users WHERE id = ?',
[id]
);
}
}
// アプリケーションサービスでの使用例
class UserApplicationService {
constructor(
private readonly userRepository: UserRepository,
private readonly userDomainService: UserDomainService
) {}
async register(command: RegisterUserCommand): Promise<void> {
// メールアドレスの重複チェック
const isDuplicated = await this.userDomainService.isEmailDuplicated(command.email);
if (isDuplicated) {
throw new Error('メールアドレスが既に使用されています');
}
// ユーザー登録
const user = new User(generateUserId(), command.name, command.email);
await this.userRepository.save(user);
}
}
アプリケーションサービス
これまでのコード例でも登場していましたが、アプリケーションサービスは、システムの機能を実現するための調整役として機能し、以下のような責務を持ちます。
- ドメインオブジェクトの調整(エンティティ、値オブジェクト、ドメインサービスの連携)
- リポジトリを使用したデータの永続化
- トランザクション管理
アプリケーションサービスの設計原則
- ドメインロジックを含まない(これはエンティティやドメインサービスの責務)
- シンプルな処理フローに保つ
- 技術的な実装の詳細(データベースやフレームワーク)に依存しない
class UserApplicationService {
constructor(
private readonly userRepository: UserRepository,
private readonly userDomainService: UserDomainService
) {}
// ユースケース: ユーザー登録
async register(email: string): Promise<void> {
// ドメインサービスを使用したドメインルールの検証
const isDuplicated = await this.userDomainService.isEmailDuplicated(command.email);
if (isDuplicated) {
throw new Error('メールアドレスが既に使用されています');
}
// エンティティの作成と永続化
const user = new User(generateUserId(), email);
await this.userRepository.save(user);
}
}
ファクトリ
ファクトリは、複雑なオブジェクトの生成ロジックをカプセル化するためのパターンです。エンティティや値オブジェクトの生成が複雑な場合、その生成ロジックをファクトリクラスに移譲することで、オブジェクトの生成と使用の関心を分離できます。
ファクトリを使用する状況
- オブジェクトの生成に複雑なロジックが必要な場合
- 生成時にビジネスルールの検証が必要な場合
- 複数のオブジェクトを協調して生成する必要がある場合
- オブジェクトの生成方法を統一したい場合
ユーザーに連番を割り当てる場合のサンプルコードが以下になります。
// メールアドレス(値オブジェクト)
class EmailAddress {
constructor(private readonly value: string) {
this.validate(value);
}
private validate(email: string): void {
if (!email.includes('@')) {
throw new Error('Invalid email format');
}
}
getValue(): string {
return this.value;
}
}
// ユーザーエンティティ
class User {
constructor(
private readonly id: string,
private readonly sequenceNumber: number,
private email: EmailAddress
) {}
getId(): string {
return this.id;
}
getSequenceNumber(): number {
return this.sequenceNumber;
}
getEmail(): EmailAddress {
return this.email;
}
}
// ユーザーファクトリ
class UserFactory {
constructor(private readonly userRepository: UserRepository) {}
async create(email: string): Promise<User> {
// メールアドレスの値オブジェクトを作成
const emailAddress = new EmailAddress(email);
// 次の連番を取得
const nextSequenceNumber = await this.getNextSequenceNumber();
// ユーザーIDを生成(例:USER_0001のような形式)
const userId = `USER_${nextSequenceNumber.toString().padStart(4, '0')}`;
// ユーザーオブジェクトを生成して返却
return new User(
userId,
nextSequenceNumber,
emailAddress
);
}
private async getNextSequenceNumber(): Promise<number> {
// 最後に使用された連番を取得
const lastUser = await this.userRepository.findLastCreatedUser();
if (!lastUser) {
return 1; // 最初のユーザーの場合
}
return lastUser.getSequenceNumber() + 1;
}
}
// リポジトリインターフェース
interface UserRepository {
save(user: User): Promise<void>;
findLastCreatedUser(): Promise<User | null>;
findByEmail(email: EmailAddress): Promise<User | null>;
}
// アプリケーションサービス
class UserApplicationService {
constructor(
private readonly userFactory: UserFactory,
private readonly userRepository: UserRepository,
private readonly userDomainService: UserDomainService
) {}
async register(email: string): Promise<void> {
// メールアドレスの値オブジェクトを作成
const emailAddress = new EmailAddress(email);
// メールアドレスの重複チェック
const isDuplicated = await this.userDomainService.isEmailDuplicated(emailAddress);
if (isDuplicated) {
throw new Error('メールアドレスが既に使用されています');
}
// ファクトリを使用してユーザーを作成
const user = await this.userFactory.create(email);
// 永続化
await this.userRepository.save(user);
}
}
レイヤードアーキテクチャ
レイヤードアーキテクチャとは
レイヤードアーキテクチャは、ソフトウェアを関心事ごとに層(レイヤー)に分割する設計手法です。各層は特定の責務を持ち、システムの保守性、拡張性、テスト容易性が向上します。
DDDとレイヤードアーキテクチャの関係
ドメイン駆動設計では、ビジネスロジックを明確に分離し、ドメインモデルに焦点を当てることを重視します。レイヤードアーキテクチャはこの考え方を実現するための具体的な構造を提供し、ドメインロジックを他の技術的な関心事から分離する手段として機能します。
各層の責務と特徴
プレゼンテーション層(ユーザーインターフェース層)
- システムの外部とのインタラクションを担当
- リクエストの受付とレスポンスの整形
- 入力値の基本的なバリデーション
- アプリケーション層のサービスを呼び出し
アプリケーション層
- ユースケースの実現を担当
- ドメインオブジェクトの調整役として機能
- トランザクション管理
- セキュリティやログなどの技術的な制御
- ドメインオブジェクトの生成・保存の制御
ドメイン層
- ビジネスロジックの中核を担当
- エンティティ、値オブジェクト、ドメインサービスなどのドメインオブジェクトを配置
- ビジネスルールやドメインロジックの実装
- 技術的な実装の詳細から独立
- 他の層に依存しない最も重要な層
インフラストラクチャ層
- データベースアクセス、外部APIとの通信、ファイルI/O等の実装
- リポジトリの具体的な実装
層間の依存関係
- 依存の方向は上位層から下位層への一方向のみ
- インフラストラクチャ層は依存性逆転の原則により、ドメイン層で定義されたインターフェースを実装
- 各層は直接の下位層にのみ依存することが理想的
レイヤードアーキテクチャの利点
- 関心事の分離による保守性の向上
- テストの容易さ(各層を独立してテスト可能)
- 実装の詳細を隠蔽することによる変更の影響範囲の限定
サンプルプロジェクト
例としてブログ投稿システムを作成しました。このシステムでは、ドメイン駆動設計とレイヤードアーキテクチャの原則に従って実装を行っています。
機能的にはシンプルなCRUD操作が中心であり、複雑なドメインルールやビジネスロジックが存在しないため、ドメイン駆動設計の恩恵をあまり活かせるプロジェクトではありませんが、実際にシステムを1から実装して基本的な概念とパターンを整理することを目的としています。
サンプルプロジェクトのリポジトリ
機能要件
ユーザー管理
- ユーザー登録
- ユーザー取得
- ユーザー削除
記事管理
- 記事投稿
- 記事取得
- 記事削除
なお、簡潔さを優先するためログイン機能などは実装していません。
ディレクトリ構成
src/
-
interface/: インターフェース層
- controllers/: HTTPリクエスト処理
- middlewares/: ミドルウェア関数
- routes/: ルート定義
-
application/: アプリケーション層
- services/: アプリケーションサービス
- commands/: コマンドオブジェクト
- errors/: カスタムエラークラス
-
domain/: ドメイン層
- models/: エンティティおよび値オブジェクト
- services/: ドメインサービス
- repositories/: リポジトリインターフェースの定義
-
infrastructure/: インフラ層
- prisma/: Prisma関連の設定
- repositories/: リポジトリの具体的な実装
tests/
- unit/: 単体テスト
- helpers/: テストヘルパー関数
技術スタック
- Docker
- TypeScript
- Node.js
- Express
- Prisma
- SQLite
- Vitest
- ESLint
- Prettier
- GitHub Actions (CI)
ExpressやPrismaなど実際の開発で利用する技術を使って実装を行いました。単体テストも実装しているので構造などが参考になれば幸いです。
Discussion