この記事で伝えたいこと
- ドメイン駆動設計は必要な技術だけ抽出して扱えば良い
- ドメイン駆動設計はオブジェクト指向で書かれることが多いが、関数型でも適用が可能であるということ
- アジャイル開発でのスクラムみたくPOが近くにいればいるほど、ユビキタス言語を用いて説明するのが良い
- ドメイン駆動設計はプロダクトデザインで使われる戦略的DDD、実際のコードに落とし込む戦術的DDDの2つが存在する
- ドメインモデル貧血症にならないように、ドメインの集約方法には気をつけよう。もし無理そうであればファイル名や関数名に命名規則を設けて何のドメインに対する振る舞いなのかを明示すると良いだろう
関数型プログラミングとドメイン駆動設計(DDD)の概要
まずは概要から導入する。
関数型プログラミングの定義はこのようにされている。
関数型プログラミング(Functional Programming, FP)は、数学的な関数を主軸にしたプログラミングスタイル。このスタイルでは、プログラムを関数の組み合わせとして構築し、状態の変更や副作用を避けることを重視する。
DDDについても、定義はこのようにされている。
ドメイン駆動設計(DDD)は、ビジネスドメインの知識を中心に据え、ドメインモデルを構築し、それに基づいてソフトウェアを設計・開発する手法。
DDDについては上記では理解が難しいため、DDDの基本理解として情報を以下に記す。
ドメイン駆動設計(DDD)の基本
DDDの主要な概念を説明し、エンティティや値オブジェクトなどの基本要素について詳しく解説します。ユビキタス言語の重要性についても触れ、実際のプロジェクトでの適用方法を示す。
そもそもドメイン(Domain)とは何か?
まず、DDDの話をする前に、ドメインとは何かを説明する必要がある。
端的にDDDにおいてのドメインとは、「ソフトウェアを使って問題解決しようとしている領域」のことを指す。
その領域について例を挙げるとするならば、ユーザーというドメインが挙げられる。
アプリケーションを作る上で、ユーザーへの何かしらの処理があるとするとき、ユーザーは氏名やメアドを持っている(エンティティ)や、削除や登録、編集などのユーザーへの何かしらの変更(振る舞い)があったりする。
これら全てがユーザーというドメインなのだ。
DDDの基本概念と目的
ドメインの基本的概念がわかったところで、次にDDDの基本概念と目的を記載する。
ドメイン駆動設計(DDD: Domain-Driven Design)は、ソフトウェア開発においてビジネスドメインの知識を中心に据え、ドメインモデルを構築し設計する手法のことを指す。
DDDの目的は、複雑なビジネスロジックを正確にモデル化し、システム全体がビジネスニーズに適応できるうにすることである。
これにより、ビジネスエキスパートと開発者が共通の言語(ユビキタス言語)を使用してコミュニケーションを行い、ビジネスの本質を反映したシステムを構築することができる。
DDDは、特に大規模で複雑なシステムにおいて効果を発揮し、ビジネスの変化にも柔軟に対応できる構造を提供することができる。特にドメインごとに領域が分かれているため新規の機能を追加しやすいことや、コードの保守性が上がるというメリットもある。
ユビキタス言語とは?
https://www.google.com/url?sa=i&url=https%3A%2F%2Fnote.com%2Fsziaoreo%2Fn%2Fn43e02c424e4f&psig=AOvVaw0MGxsmrgNH2gVwnSyJ8jeY&ust=1730905810732000&source=images&cd=vfe&opi=89978449&ved=0CBQQjRxqFwoTCOCvm-K8xYkDFQAAAAAdAAAAABBY
ユビキタス言語とは、開発チーム全員が共通して使用する言語であり、ビジネスドメインの専門用語や概念を統一することを目的とするものであり、特にアジャイル開発のスクラムチーム内でプロダクトオーナー(PO)がいる場合、その重要性はさらに増する。
スクラムチームでは、POがビジネスの要件や優先順位を決定し、開発チームに伝える役割を担う。
この際、ユビキタス言語を使用することで、POと開発チームの間でのコミュニケーションが円滑になり、誤解や認識のズレを防ぐことができるのである。
Devからすれば、顧客ドメインに対して登録という処理を行う場合、RegisterUser
やCreateUser
などのサービス名の決定権はDevにあり、POにサービスの名前と処理内容を直接伝えたところでPOからすれば「登録サービス」としか理解できない。
だったら、ユビキタス言語として「ユーザ登録サービス」という名前でお互いに会話を行うのが良いだろう。
ユビキタス言語は、DevとPO間だけでなく、チーム全員の共通言語であるということだ。プロダクトデザイン等で一部のチームしか理解できない言葉を用いると仕様の認識齟齬が起きてしまう。
ヘキサゴナルアーキテクチャとは?
ヘキサゴナルアーキテクチャ(別名ポートアンドアダプターアーキテクチャ)は、Alistair Cockburn氏によって提唱されたソフトウェア設計パターンである。このアーキテクチャは、システムを疎結合なコンポーネントに分割し、ビジネスロジックと外部インターフェースを明確に分離することを目的としている。
ヘキサゴナルアーキテクチャでは、アプリケーションのコア部分(ビジネスロジック)を中心に据え、その周囲に「ポート」と「アダプター」を配置する。ポートは、アプリケーションが外部と通信するためのインターフェースであり、アダプターはそのインターフェースを実装する具体的な手段である。例えば、ユーザーインターフェースやデータベースアクセスはアダプターを通じて行われる。
このアプローチにより、ビジネスロジックは外部の技術的な詳細から独立し、テストや保守が容易になることが期待される。また、異なるインターフェースや技術を簡単に交換できるため、システムの柔軟性が向上する。
特に、DDDでヘキサゴナルアーキテクチャを適用することにより、ドメインのビジネスロジックを中心として設計を行うことができるので、相性がいいとされている。
エンティティ、値オブジェクト、アグリゲート、リポジトリ、サービスとは?
ドメイン駆動設計(DDD)では、システムをビジネスドメインに基づいて設計するためのいくつかの重要な概念がある。以下に、オブジェクト指向で書かれたTypeScriptを用いてそれぞれの概念について説明する。
エンティティ(Entity)
エンティティは、一意の識別子を持ち、ライフサイクルを通じてその識別子によって区別されるオブジェクトのこと。エンティティは、属性と振る舞いを持ち、ビジネスロジックを含むことができる。この例では、カスタマードメインにIDや氏名、メールアドレスが入っているが、それらは上位モデルの使用する側に値は委ねられている。
例えば、メールアドレスは、「aaa@example.com」だろうが「bbb@example.com」でもどちらでも構わないが、メールアドレスが分かれば、そのユーザを一意に探し当てる事ができる。
例:
class Customer {
constructor(
public customerId: string,
public name: string,
public email: string
) {}
updateEmail(newEmail: string) {
this.email = newEmail;
}
}
値オブジェクト(Value Object)
値オブジェクトは、属性のみを持ち、不変であるオブジェクトのこと。値オブジェクトは、エンティティの属性として使用され、同じ値を持つ場合は等価とみなされる。この例では、アドレスドメインに住所が入っているが、都道府県に東京都を指定したところで、上位のモデルに対して一意の住所はわからない。値オブジェクトは定数群として扱う。
例:
class Address {
constructor(
public postalCode: string,
public prefecture: string,
public city: string,
public street: string
) {}
}
アグリゲート(Aggregate)
アグリゲートは、関連するエンティティや値オブジェクトの集合であり、一貫性の境界を定義するもの。”集約”という名称としてもある。アグリゲートルートは、IDや氏名といったものを持つため、アグリゲート全体の一貫性を保証する責任を持つエンティティを指す。
例:
class Order {
constructor(
public orderId: string,
public customer: Customer,
public items: OrderItem[]
) {}
addItem(item: OrderItem) {
this.items.push(item);
}
}
class OrderItem {
constructor(
public productId: string,
public quantity: number,
public price: number
) {}
}
リポジトリ(Repository)
リポジトリは、エンティティやアグリゲートの永続化を管理するオブジェクトのことを指す。リポジトリは、データベース操作を抽象化し、ドメインモデルからデータアクセスの詳細を隠すことができる。
例:
interface CustomerRepository {
save(customer: Customer): void;
findById(customerId: string): Customer | null;
}
class CustomerRepositoryImpl implements CustomerRepository {
private customers: Map<string, Customer> = new Map();
save(customer: Customer): void {
this.customers.set(customer.customerId, customer);
}
findById(customerId: string): Customer | null {
return this.customers.get(customerId) || null;
}
}
ドメインサービス(Domain Service)
ドメインサービスは、エンティティや値オブジェクトに属さないビジネスロジックを提供するオブジェクトのことを指す。ドメインサービスは、複数のエンティティや値オブジェクトにまたがる操作を行うことができる。
例:
class OrderService {
constructor(private orderRepository: OrderRepository) {}
placeOrder(customer: Customer, items: OrderItem[]): Order {
const order = new Order(generateUniqueId(), customer, items);
this.orderRepository.save(order);
return order;
}
}
関数型プログラミングとDDDの統合
次に、実際に関数型プログラミングの特性を活かしてDDDを実践する方法を説明する。型システムを活用したドメインモデルの設計や、ビジネスロジックをドメインモデルに組み込む方法について具体的な例を示す。
関数型プログラミングの特性を活かしたDDDの実践方法
関数型プログラミング(FP)の特性を活かしてDDDを実践するには、純粋関数、不変性、高階関数などの概念を取り入れることが重要となる。
純粋関数を使用することで、副作用を避け、予測可能なコードを実現します。不変性を保つことで、データの一貫性を確保し、バグの発生を防ぐことができるという特徴がある。
高階関数を活用することで、関数の再利用性を高め、コードの柔軟性を向上させます。これらの特性を組み合わせることで、ビジネスロジックを明確にし、保守性の高いシステムを構築することができる。
型システムを活用したドメインモデルの設計
型システムを活用することで、ドメインモデルの設計を強化することができる。TypeScriptの強力な型システムを利用すれば、ドメインのルールや制約を型で表現することができる。
例えば、ユーザーのメールアドレスや名前などの属性を型で定義し、コンパイル時にバリデーションを行うことで、実行時のエラーを減らすことが可能になる。
また、型エイリアスやインターフェースを使用して、ドメインモデルの構造を明確にし、コードの可読性を向上させることができるという特徴もある。
ビジネスロジックをドメインモデルに組み込む方法
ビジネスロジックをドメインモデルに組み込むことで、ドメインモデル貧血症を防ぐことができる。(ドメインモデル貧血症に関しては、記事下部で説明を行う)
例えば、ユーザー登録のビジネスロジックをUser
に組み込むことで、モデル自体がビジネスルールを持つようにします。以下のようなコードのイメージだ。
type User = {
id: string;
name: string;
email: string;
};
const registerUser = (name: string, email: string): User => {
const id = Math.random().toString(36).substr(2, 9);
return { id, name, email };
};
このようにすることで、User
というドメインモデルがビジネスロジックを持ち、コードの可読性と保守性、ドキュメントとしての理解のしやすさが向上する。
上のようにビジネスロジックをドメインモデルに組み込むことで、削除や更新といった新しいビジネスロジックの追加にも柔軟に対応することができる。
加えて、ドメインモデルにビジネスロジックを含ませることによって、このドメインが定義されている当ファイルがドキュメントの役割を果たすことができるというメリットがある。
このファイルを見れば、ユーザというドメインに対してどんな機能があるのかを知ることができるのだ。
実践例: ユーザー登録APIの設計
実践として、戦術的DDDを適用し、AWSのLambdaにTypeScriptでコードを書き、APIgateway経由でLambdaに到達するWebAPIとして作成してみた。
今回は特に関数型プログラミングで記述しているのと、ドメインモデルを中心としてDDDの基本設計であるヘキサゴナルアーキテクチャになっていることも注目してほしい。
ファイル構造の例
src/
├── application/
│ └── registerUser.ts
├── domain/
│ ├── models/
│ │ └── user.ts
│ ├── services/
│ │ └── userService.ts
├── infrastructure/
│ ├── repositories/
│ │ └── userRepositoryImpl.ts
│ └── utils/
│ └── generateUniqueId.ts
└── interfaces/
├── controllers/
│ └── userController.ts
└── repositories/
└── userRepository.ts
├── lambda/
│ └── handler.ts
コンポーネント図
各ファイルの内容
import { generateUniqueId } from '../../infrastructure/utils/generateUniqueId';
export type User = {
id: string;
name: string;
email: string;
};
export const createUser = (name: string, email: string): User => ({
id: generateUniqueId(),
name,
email,
});
export const generateUniqueId = (): string => {
return Math.random().toString(36).substr(2, 9);
};
import { User, createUser } from '../models/user';
import { UserRepository } from '../../interfaces/repositories/userRepository';
export const registerUser = (userRepository: UserRepository) => (name: string, email: string): User => {
const user = createUser(name, email);
userRepository.save(user);
return user;
};
import { registerUser as registerUserService } from '../domain/services/userService';
import { userRepositoryImpl } from '../infrastructure/repositories/userRepositoryImpl';
export const registerUser = registerUserService(userRepositoryImpl);
import { User } from '../../domain/models/user';
export interface UserRepository {
save(user: User): void;
}
import { UserRepository } from '../../interfaces/repositories/userRepository';
import { User } from '../../domain/models/user';
export const userRepositoryImpl: UserRepository = {
save: (user: User) => {
// データベースにユーザーを保存する処理
console.log('User saved:', user);
}
};
import { registerUser } from '../../application/registerUser';
export const handleRegisterUser = async (event) => {
const { name, email } = JSON.parse(event.body);
const user = registerUser(name, email);
return {
statusCode: 200,
body: JSON.stringify(user),
};
};
import { handleRegisterUser } from '../interfaces/controllers/userController';
export const handler = async (event) => {
return await handleRegisterUser(event);
};
ドメインモデル貧血症にならないために
ドメインモデル貧血症とは?
ドメインモデル貧血症(Anemic Domain Model)とは、ドメインモデルが十分なビジネスロジックを持たず、単なるデータキャリアとして機能する状態を指す。
具体的には、ドメインオブジェクトがゲッターやセッターのみを持ち、ビジネスロジックがサービス層や他の場所に分散している場合に発生する。
以下のようなソースコードが該当する。
export type User = {
id: string;
name: string;
email: string;
};
export const generateUniqueId = (): string => {
return Math.random().toString(36).substr(2, 9);
};
import { User } from '../models/user';
import { UserRepository } from '../../interfaces/repositories/userRepository';
import { generateUniqueId } from '../../infrastructure/utils/generateUniqueId';
export const registerUser = (userRepository: UserRepository) => (name: string, email: string): User => {
const id = generateUniqueId();
const user: User = { id, name, email };
userRepository.save(user);
return user;
};
このようなソースの場合、ドメインモデルのUserモデルには型情報しか存在しない。
つまり、このドメインにはどのような業務ロジックが入っているのかわからないのだ。
上記のようなドメインモデル貧血症を回避するためには、ビジネスロジックをドメインオブジェクトに組み込むことが重要である。
以下に対策法をいくつか並べてみた。
-
ビジネスロジックの移行: ビジネスロジックをサービス層からドメインオブジェクトに移行する。例えば、ユーザーの登録やバリデーションのロジックを
User
クラスに含めることで、ドメインオブジェクトが単なるデータキャリアではなく、ビジネスルールを持つようになる -
メソッドの追加: ドメインオブジェクトに関連する操作をメソッドとして追加する。例えば、
User
クラスにregister
やvalidateEmail
といったメソッドを追加することで、オブジェクト自身がその責任を持つようにする -
不変性の確保: ドメインオブジェクトを不変にすることで、状態の変更を防ぎ、予測可能な動作を保証する。オブジェクトの状態を変更する場合は、新しいインスタンスを返すようにする
-
テストの充実: ドメインオブジェクトにビジネスロジックを組み込むことで、ユニットテストが容易になる。各メソッドを個別にテストすることで、ビジネスルールが正しく実装されていることを確認できる
-
リファクタリングの継続: コードの変更や追加があった場合は、常にリファクタリングを行い、ドメインモデルが貧血症にならないように注意する
DDDでは、コードそのものがドキュメントになるというのがセオリーだ。
ドメインモデル貧血症になってしまうとドメインに対しての振る舞いがわからないため、ドキュメントとしての機能を果たすことができない。
ドメインモデル貧血症になった結果、新規機能を追加しにくいレポジトリが出来上がってしまうので、注意点が必要なのだ。
まとめ
-
ドメインの定義: DDDにおける「ドメイン」とは、ソフトウェアを使って解決しようとするビジネス上の問題領域を指し、実際のアプリケーションでの処理対象となる概念(例:ユーザー)を含む
-
DDDの目的: ドメイン駆動設計(DDD)は、ビジネスドメインに基づいてソフトウェアの設計を行い、ビジネスニーズに柔軟に対応できるシステムを構築することが目的である。特に複雑なビジネスロジックのモデル化に有効
-
ユビキタス言語: DDDでは、開発者とビジネスエキスパートが共通の言語(ユビキタス言語)を使用することで、誤解や認識のズレを防ぎ、効率的なコミュニケーションを実現する
-
ヘキサゴナルアーキテクチャ: ヘキサゴナルアーキテクチャは、システムのビジネスロジックと外部インターフェースを分離し、疎結合にすることでテストや保守が容易になる設計パターン
-
DDDの主要コンセプト(エンティティ、値オブジェクト、アグリゲート、リポジトリ): DDDの設計では、エンティティ(識別可能なオブジェクト)、値オブジェクト(不変の属性を持つ)、アグリゲート(関連するエンティティの集合)、リポジトリ(データの永続化管理)などを適切に活用する
-
ドメインサービス: ドメインサービスは、複数のエンティティや値オブジェクトにまたがるビジネスロジックを提供し、ドメインモデルに含めるべきではないロジックを分離して管理する
-
関数型プログラミング(FP)の活用: DDDにおいて関数型プログラミングを取り入れると、純粋関数、不変性、高階関数を活用して、ビジネスロジックを明確にし、予測可能で保守性の高いシステムが構築できる
-
型システムの活用: TypeScriptなどの型システムを活用して、ドメインモデルに対する制約やルールを型で表現し、コンパイル時にバリデーションを行うことで、実行時エラーを減らすことが可能
-
ドメインモデル貧血症の回避: ドメインモデル貧血症(ビジネスロジックがサービス層にのみ存在し、モデル自体にロジックがない状態)を回避するために、ビジネスロジックをドメインモデルに組み込むことが重要
-
DDDとFPの統合によるメリット: DDDと関数型プログラミングを組み合わせることで、ドメインモデルの設計が強化され、ビジネスロジックが整然と保たれ、コードの可読性、保守性、テストの容易さが向上する
参考文献
- エリック・エヴァンスのドメイン駆動設計
- ヘキサゴナルアーキテクチャ(ポートアンドアダプター)とは何か - Qiita. https://qiita.com/cocoa-maemae/items/b08c4cf95d47e314e2dc.
- ヘキサゴナルアーキテクチャって? - 初心者向けのやさしい解説 - Qiita. https://qiita.com/Shin_728/items/4f9e62830eb45bb0f9c5.
- ヘキサゴナルアーキテクチャ図とは - サイバーメディアン. https://bing.com/search?q=%e3%83%98%e3%82%ad%e3%82%b5%e3%82%b4%e3%83%8a%e3%83%ab%e3%82%a2%e3%83%bc%e3%82%ad%e3%83%86%e3%82%af%e3%83%81%e3%83%a3+500%e6%96%87%e5%ad%97+%e8%aa%ac%e6%98%8e.
- 【サルが書く】DDDで使われがちのアーキテクチャを紹介しよう! - Qiita. https://qiita.com/hajimemath/items/9c460ad220afd635e7ef.
- ヘキサゴナルアーキテクチャ図とは - サイバーメディアン. https://www.cybermedian.com/ja/what-is-hexagonal-architecture-diagram/.
- Hexagonal Architecture - nrslib. https://nrslib.com/hexagonal-architecture/.