はじめに
この投稿はアイスタイル Advent Calendar 2024 の13日目の記事です。
アイスタイルで主にバックエンドエンジニアを担当している24卒のuchimuramです。
今年から新卒として、初めての業務ということで様々なことを学び、初めて実施する内容も多かったことから、とても新鮮で濃密な1年を過ごすことができました。
その中でも、配属後に部署で開発するのに必要となったドメイン駆動設計について、自分なりにまとめてみようと思います。
記事の対象読者と学べる内容について
本記事では、主に業務で初めてドメイン駆動設計にふれる方や、DDDが導入されたプロジェクトに参画したメンバーがDDDの基本的な導入部分を知るときに適した内容となります。また、自分みたいにRuby on RailsのようなMVCアーキテクチャくらいしか触ってこなかった方などもぜひ読んでみてください。
ドメイン駆動設計とは
みなさんはドメイン駆動設計(Domain Driven Design )という言葉を聞いて、「ドメイン」という言葉の意味を理解していますでしょうか?自分は「ドメイン」という言葉を聞いても「URLのhttps://◯◯◯.com
」くらいのイメージしかありませんでした。
そもそもドメインとは「領域・分野」という意味があります。そして、ドメイン駆動設計とは
ドメイン駆動設計(ドメインくどうせっけい、英語: domain-driven design、DDD)は主要なソフトウェア設計手法の一つであり、ドメインエキスパート1の言葉に基づき、ドメインにおけるプロセスやルールをよく表現したドメインモデルを構築し、それに基づいてソフトウェア開発を行うことに主眼を置くものである。(Wiki参考)
だそうです。具体例として、アプリケーションとしては
- クチコミをしたい→ SNSアプリ
- インターネット上で買い物をしたい→ECサイト
- 硬貨や紙幣を使わずに支払いをしたい→QR決済、電子マネー
など、様々な「ドメイン」というものが存在します。
これらのユーザーの課題を解決しようとするときに現実世界のビジネスルールや業界のルールをソフトウェアで表現したものをドメインモデルといいます。
具体例をいうと、SNSアプリだと、「クチコミを投稿する」ECサイトだと「商品をカートにいれる」QR決済だと「残高を管理する」というのがあります。
実際に、プロダクトを開発していく中でドメインごとにそれぞれの必要なルールをコードで表現するみたいなイメージです。
このように、ドメインエキスパート側1がプロダクトで「こんなことがしたい」「あんなことがしたい」となったときにプロダクトへ柔軟に落とし込んでいくために、コードで表現するものが「ドメインモデル」であり、ソフトウェアの基盤となります。
そして、このドメインモデルを表現するために様々な概念があるので、詳しくは次の章で説明したいと思います。
DDDでよく出てくる概要について
値オブジェクト
値オブジェクトについて、説明します。値オブジェクトとは、ドメイン内の様々な値に関する内容をモデル化するのに利用されます。
しかし、「値」と言ってもなかなかイメージが付きづらいと思います…。
個人的には、サービスやプロダクトにおいての「概念」や「ルール」そのものを定義するみたいな考えのほうがしっくりきた感じです。
値に関することをモデル化していると言ってもイメージがつきにくいと思うので、特徴と参考例としてお金(通貨)の計算方法を例として説明してみたいと思います。
値オブジェクトの特徴として、
- 不変性の確保
- 等価性の比較
- ビジネスルールの集約
- 再利用性
が挙げられます。
不変性の確保
不変性の確保とは、オブジェクトの状態が一度設定されたあとは変更されないことを指します。値オブジェクトが持つプロパティはオブジェクトのライフサイクル間では変更されることはありません。実際のコードでは金額(amount)と通貨(currency)はreadonly
と宣言することで変更不可にすることで、セッターを定義しないことで実現することができます。
等価性
等価性とは、その値が同一だったら、オブジェクト自体も同様と考える原則です。等価性の比較をすることで「値」そのものに基づいて判定します。実際のコードでは金額(amount)と通貨(currency)は外部から直接変更できず、equelメソッドでは金額(amount)と通貨(currency)が一致している場合にtrueを返します。
ビジネスルールの集約について
ビジネスルールの集約とは、値オブジェクトが属性に関するビジネスルールを内部に内包することで、外部のサービスやクラスへ依存せずに自己完結的にルールを適応することが可能なことです。実際のコードでは、addメソッドで異なる通貨(USDとJPY)の計算ができないというビジネスルールを用いて、計算するようなイメージです。
再利用性
再利用性とは、一度作成した値オブジェクト内のビジネスルールをアプリケーション内の他の場所でも再利用できることです。再利用できることは、別にDDDでなくてもDRY原則である一度作成したメソッドやモジュールの重複を防ぐという特徴もありますが、値オブジェクトが不変であるため、他のクラスで用いても安全に利用することができます。実際のコードでも、支払い用のクラスで値オブジェクト内のaddメソッドを利用することで元の金額と割引金額(discount)を出力することができます。
このように、ビジネスルールを様々な場所で再利用できて、不変性を守ることで外部からのアクセスによるバグを防ぐことができます。
export class Money {
// amount(金額)とcurrency(通貨)はreadonlyで設定するで一度設定されると変更できない
private readonly amount: number;
private readonly currency: string;
private readonly currencyTextCount: number = 3;
constructor(amount: number, currency: string) {
if (amount < 0) {
throw new Error("金額は正の数である必要があります!");
}
if (!currency || currency.length !== currencyTextCount) {
throw new Error("通貨の単位は3文字で表現してください!");
}
this.amount = amount;
this.currency = currency;
}
// 値オブジェクトの等価性を確認するメソッド
private equals(other: Money): boolean {
return (
this.amount === other.amount &&
this.currency === other.currency
);
}
// 金額を加算した新しいインスタンスを返す
private add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("異なる通貨どうしで算出することができません!");
}
return new Money(this.amount + other.amount, this.currency);
}
// 金額と通貨を取得
private getAmount(): number {
return this.amount;
}
private getCurrency(): string {
return this.currency;
}
// 表示用のフォーマット
private toString(): string {
return `${this.amount} ${this.currency}`;
}
}
// 使用例
const money1 = new Money(100, "USD"); //
const money2 = new Money(50, "USD");
const money3 = new Money(100, "JPY")
const total = money1.add(money2);
console.log(total.toString()); // "150 USD"と表示される
console.log(money1.equals(money1)); // true :金額も通貨も一致しているため
console.log(money1.equals(money2)); // false:金額が異なるため
console.log(money1.equals(money3)); // false:通貨が異なるため
// 支払い処理用のサービス
class PaymentService {
/**
* 支払い合計金額に割引を適用し、最終金額を計算します。
* @param total 支払い合計金額
* @param discount 割引金額
* @returns 割引適用後の金額
*/
applyDiscount(total: Money, discount: Money): Money {
// 割引を引き算して最終金額を計算
const finalAmount = total.add(new Money(-discount.getAmount(), total.getCurrency()));
return finalAmount;
}
}
エンティティ
エンティティとは、他のオブジェクトと区別することができる識別子(ID)を持つオブジェクトのことです。システムの中核となるような概念をモデリングして、その状態や振る舞いを管理する役割を担います。
エンティティの主な特徴して
- 同一性
- 状態
- 振る舞い
が挙げられます。
同一性とは、一意に判別できるかという意味になります。「ID」などを用いてオブジェクトが同一かどうかを判断します。
状態については、エンティティがもつ属性やプロパティの集合のことになります。状態が変化することによって、「登録情報の変更や削除」などビジネスルールに柔軟に対応することができます。ただし、変化するといっても、同一性自体は担保したままの状態で変更するので、識別子(ID)自体は不変となります。
振る舞いとは、エンティティがもつビジネスルールや処理自体のことを指します。
こちらもイメージしにくいと思うので参考例とともに説明してみたいと思います。
参考例として、「ユーザー」・「注文」・「商品」などの概念をシステム内でまとめるときに用いたりします。今回はiPhoneなどのスマートフォンを例に説明したいと思います。スマートフォンと言っても、シリーズやモデル名・ストレージ容量・価格などによって、バラバラですよね。
このように同じiPhoneでも、モデルなどの違いによって別々の扱いをするようなイメージです。(著者はiPhoneもAndroid共に持っていますので、贔屓しているわけではありませんが、読者にイメージしやすいネタとして選んだ感じです。)
export class iPhone {
private readonly id: string; // 一意の識別子(シリアル番号など)
private model: string; // モデル名(例: iPhone 16)
private storage: number; // ストレージ容量(GB)
private price: number; // 価格(USD)
private stock: number; // 在庫数
constructor(id: string, model: string, storage: number, price: number) {
if (!id) {
throw new Error("IDが必要です。");
}
if (!model || model.trim() === "") {
throw new Error("モデル名が必要です。");
}
if (storage <= 0) {
throw new Error("ストレージ容量は0GBより大きい値を入れてください。");
}
if (price <= 0) {
throw new Error("価格は必ず正の数である必要があります。");
}
if (stock < 0 ) {
throw new Error("在庫数は必ず正の数である必要があります。");
}
this.id = id;
this.model = model;
this.storage = storage;
this.price = price;
this.stock = stock;
}
// IDによる同一性判定
public equals(other: iPhone): boolean {
return this.id === other.id;
}
// ストレージ容量をアップグレード
public upgradeStorage(newStorage: number): void {
if (newStorage <= this.storage) {
throw new Error("アップグレードするなら、現在の容量より大きいサイズを選んでください。");
}
this.storage = newStorage;
}
// 価格を変更
public changePrice(newPrice: number): void {
if (newPrice <= 0) {
throw new Error("価格は必ず正の数にするようにしてください。");
}
this.price = newPrice;
}
// 現在の状態を確認するためのメソッド
public toString(): string {
return `iPhone [ID: ${this.id}, Model: ${this.model}, Storage: ${this.storage}GB, Price: ${this.price}]`;
}
// 在庫を増加するメソッド
public addStock(amount: number): void {
if (amount <= 0) {
throw new Error("在庫数は正の数である必要があります。");
}
this.stock += amount;
}
// 在庫を減少するメソッド
public reduceStock(amount: number): void {
if (amount <= 0) {
throw new Error("在庫数は正の数である必要があります。");
}
if (amount > this.stock) {
throw new Error("在庫不足です。");
}
this.stock -= amount;
}
}
//使用例
// iPhoneの作成
const iphone1 = new iPhone("SN123456", "iPhone 16", 128, 999);
const iphone2 = new iPhone("SN789012", "iPhone 16 Pro", 256, 1199);
// 同一性の判定(シリアル番号が異なる場合)
console.log(iphone1.equals(iphone2)); // false
// ストレージ容量のアップグレード
iphone1.upgradeStorage(256);
console.log(iphone1.toString());
// iPhone [ID: SN123456, Model: iPhone 16, Storage: 256GB, Price: $999]
// 価格の変更
iphone2.changePrice(1099);
console.log(iphone2.toString());
// iPhone [ID: SN789012, Model: iPhone 16 Pro, Storage: 256GB, Price: $1099]
// 同じiPhoneを比較
const iphone3 = new iPhone("SN123456", "iPhone 16", 128, 999);
console.log(iphone1.equals(iphone3)); // true (IDが同じなので同一エンティティ)
さらにさきほど説明した値オブジェクトの違いをまとめると、このような表の内容となります。
特に、識別子の有無や比較基準・不変性の違いなどが理解するうえで重要だと感じました。
特徴 | 値オブジェクト | エンティティ |
---|---|---|
識別子 | もたない 値自体が必要 | 固有のIDが必要 一意に識別できる |
比較基準 | 値の内容が一致していれば 同一をみなす |
IDが一致していれば 同一とみなす |
不変性 | 不変であることが原則 | 可変性を持つ場合もあり状態を変更することができる |
用途 | 小さな単位での値の表現や意味をもつ値の集合 | 一意に識別できる オブジェクトとして一貫性をもたせるために利用 |
ビジネスルール | 内包して値に対する操作や計算をする | ドメイン全体の状態管理や振る舞いをする |
具体例 | 金額・名前・座標 | ユーザー・注文・商品 |
ライフサイクル | 管理不要 | 管理が必要 永続化や更新を考慮する必要がある |
設計上において必要なこと | ドメインモデル内の使いやすさと再利用性を向上させるために設計 | ドメイン全体の一貫性と 正確さを維持させるために 設計 |
永続化方法 | 他のオブジェクトに含める形で永続化される | 主にリポジトリを介して永続化される |
リポジトリ
リポジトリとは、エンティティや集約を永続化させるための設計パターンで、データの保存場所を抽象化して、ビジネスルールとインフラストラクチャを分離します。
抽象化をinterfaceとして表現し、クラスやオブジェクトが満たすような振る舞いを定義する役割をもつものとなります。
※ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本の引用
リポジトリはドメイン層のエンティティを永続化するための仲介役の役割をしています。
今回はイメージしやすいようなするためのアプリケーション層・ドメイン層・インフラストラクチャ層をアーキテクチャを用いて説明したいと思います。
アプリケーション層
アプリケーション層では、各ユースケースに応じた処理の流れを管理します。そこからリポジトリが呼び出され、ドメイン層で定義された値オブジェクト、エンティティなどのビジネスルールを使ってデータの操作を指示します。
ドメイン層
ドメイン層では、ビジネスルールを実行するための値オブジェクトやエンティティを扱います。またinterfaceをドメイン層に置く理由として、この層にinterfaceとして存在させることによって、永続化などの具体的な実装には依存しない構造となっています。
インフラストラクチャ層
インフラストラクチャ層では、ドメイン層のリポジトリにあるinterfaceを具体的に実装することで、実際のDBとのやり取りを行います。この層ではORMやSQLを扱う部分となります。
このように、interfaceをドメイン層に置くことで、ドメイン層がインフラストラクチャ層に依存しない設計を実現できます。また、インフラストラクチャ層の実装を変更してもドメイン層に影響を与えず、ドメイン層とインフラストラクチャ層を分離する役割を果たします。
※DDDを実践するための手引き(リポジトリパターン編)内にある各層の役割と依存の方向性を表した図
さらに、依存しないようすることで
- DBの変更や構造に変更があってもに容易に対応しやすい
- interfaceを拡張したクラスをmockすることで、DB操作なしで単体テストがしやすい
のようなメリットがあります。
参考例として、さきほどのエンティティの延長として、スマートフォンの例で説明したいと思います。iPhoneの各モデルごとの在庫状況を取得・追加・削除などの操作する際の例です。
//Interface
export interface iPhoneRepository {
findById(id: string): Promise<iPhone | null>; // IDで検索
findAll(): Promise<iPhone[]>; // 全件取得
save(iphone: iPhone): Promise<void>; // 保存(新規追加または更新)
deleteById(id: string): Promise<void>; // IDで削除
}
//実体クラス
import { iPhone } from "./iPhone";
import { iPhoneRepository } from "./iPhoneRepository";
export class InMemoryiPhoneRepository implements iPhoneRepository {
private dataStore: iPhone[] = []; // メモリ上のデータストア
async findById(id: string): Promise<iPhone | null> {
const iphone = this.dataStore.find((item) => item.id === id);
return iphone || null;
}
async findAll(): Promise<iPhone[]> {
return [...this.dataStore];
}
async save(iphone: iPhone): Promise<void> {
const existingIndex = this.dataStore.findIndex((item) => item.id === iphone.id);
if (existingIndex !== -1) {
this.dataStore[existingIndex] = iphone; // 更新
} else {
this.dataStore.push(iphone); // 新規追加
}
}
async deleteById(id: string): Promise<void> {
this.dataStore = this.dataStore.filter((item) => item.id !== id);
}
}
//使用例
import { iPhone } from "./iPhone";
import { InMemoryiPhoneRepository } from "./InMemoryiPhoneRepository";
(async () => {
const repository = new InMemoryiPhoneRepository();
// iPhone商品の作成と保存
const iphone1 = new iPhone("1", "iPhone 16", 128, 999, 50);
const iphone2 = new iPhone("2", "iPhone 16 Pro", 256, 1199, 30);
await repository.save(iphone1);
await repository.save(iphone2);
// 全件取得
console.log("All iPhones:", await repository.findAll());
// IDで商品を検索
const foundiPhone = await repository.findById("1");
console.log("Found iPhone:", foundiPhone);
// 在庫を減らす
if (foundiPhone) {
foundiPhone.reduceStock(5);
await repository.save(foundiPhone); // 更新
}
console.log("更新した情報について:", await repository.findById("1"));
// 商品を削除
await repository.deleteById("2");
console.log("削除したあとの一覧:", await repository.findAll());
})();
まとめ
今回、業務でDDDを用いた開発をすることになったので、自分なりに基礎的な内容である「値オブジェクト」・「エンティティ」・「リポジトリ」についてまとめてみました。
今まではRuby on RailsのようなMVCアーキテクチャしか触ってこなかったので、ビジネスルールが増えたり、複雑になったりするとModelが肥大化して分かりにくかった印象があるのに対して、役割や責任範囲がより明確になったことで可読性が向上し、変更に対して柔軟に対応できる設計だと感じました。
さらに、ビジネスルールがModelとして独立しているので、単体テストがしやすいという業務の上ではメリットが多い設計手法であると再認識することができました。
しかし、デメリットとして初回時の導入コストや学習コストがMVCフレームワークと比較して高いと感じました。特にビジネスルールを表現するために必要な前提知識やDDDの概念を理解するのに、時間がかかると思います。
今後の展望として、ビジネスルールを柔軟に取り入れたり、プロジェクトを円滑に進めていくためにも、DDDの内容をより深く理解する必要があると感じました。また、ドメインエキスパート側のイメージにより近づけるためにも今回説明した内容以外に、「ドメインサービス」・「集約」などの発展的な内容やDDDの考え方を用いた各アーキテクチャの特徴についても理解することで業務の方でも活用してプロダクト開発に貢献していきたいと思います。
本日の記事は以上となります。
引き続き アイスタイル Advent Calendar 2024 をお楽しみください。
参考文献
- ウィキペディア:ドメイン駆動設計
- ドメイン駆動設計入門 ボトムアップでわかる! ドメイン駆動設計の基本
- 【DDD入門】TypeScript × ドメイン駆動設計ハンズオン
- DDDを実践するための手引き(リポジトリパターン編)