はじめに
この記事では、DDD (Domain-Driven Design:ドメイン駆動設計) について、Node.js / TypeScript / Express / Prisma 環境で構築したユーザー管理APIをリファクタリングしながら学んでいきます。
また、DDD には多くの専門用語・概念があり、学習するうえでハードルに感じると思います。主要な概念については、リファクタリングの過程で、一つずつ理解していきます。
開発環境
開発環境は以下の通りです。
- Windows11
- Docker Engine 27.0.3
- Docker Compose 2
- PostgreSQL 18.1
- Node.js 24.11.0
- npm 11.6.2
- TypeScript 5.9.3
- Express 5.1.0
- Prisma 6.18.0
- Zod 4.1.12
元のコードの問題点
まず、元のコードを確認します。
import { PrismaClient } from "@prisma/client";
import express, { Request, Response } from "express";
import { validate } from "./middlewares/validate.middleware";
import {
CreateUserInput,
createUserSchema,
UpdateUserInput,
updateUserSchema,
UserIdParam,
userIdSchema,
} from "./schemas/user.schema";
import z from "zod";
const app = express();
const prisma = new PrismaClient();
const PORT = process.env.PORT || 3000;
// JSONリクエストボディをパースするミドルウェア
app.use(express.json());
// ヘルスチェック用エンドポイント
app.get("/", (req, res) => {
res.json({ message: "Server is running" });
});
// サーバー起動
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
// アプリケーション終了時にPrisma接続をクリーンアップ
process.on("SIGINT", async () => {
await prisma.$disconnect();
process.exit(0);
});
// ユーザー作成
app.post(
"/users",
validate(z.object({ body: createUserSchema })),
async (req: Request<{}, {}, CreateUserInput>, res: Response) => {
try {
const { email, name } = req.body;
if (!email || !name)
return res.status(400).json({ error: "Email and name are required" });
const user = await prisma.user.create({
data: {
email,
name,
},
});
res.status(201).json(user);
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to create user" });
}
}
);
// 全ユーザー取得
app.get("/users", async (_: Request, res: Response) => {
try {
const users = await prisma.user.findMany();
res.json(users);
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to fetch users" });
}
});
// 特定ユーザー取得
app.get(
"/users/:id",
validate(z.object({ params: userIdSchema })),
async (req: Request<UserIdParam>, res: Response) => {
try {
const { id } = req.params;
const user = await prisma.user.findUnique({
where: { id: Number(id) },
});
if (!user) return res.status(404).json({ error: "User not found" });
res.json(user);
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to fetch user" });
}
}
);
// ユーザー更新
app.put(
"/users/:id",
validate(
z.object({
params: userIdSchema,
body: updateUserSchema,
})
),
async (req: Request<UserIdParam, {}, UpdateUserInput>, res: Response) => {
try {
const { id } = req.params;
const { email, name } = req.body;
const user = await prisma.user.update({
where: { id: Number(id) },
data: {
...(email && { email }),
...(name && { name }),
},
});
res.json(user);
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to update user" });
}
}
);
// ユーザー削除
app.delete(
"/users/:id",
validate(
z.object({
params: userIdSchema,
})
),
async (req: Request<UserIdParam>, res: Response) => {
try {
const { id } = req.params;
await prisma.user.delete({
where: { id: Number(id) },
});
res.status(204).send();
} catch (error) {
console.error(error);
res.status(500).json({ error: "Failed to delete user" });
}
}
);
このコードには以下の問題があります。
- ビジネスルールがコントローラーに直接書かれている
- データベース操作がコントローラーから直接行われている
- ドメインの概念が明確でない
- テストがしにくい構造
これらをDDDの考え方を用いて、以下の順番で改善していきます。
- 値オブジェクトの作成
- エンティティの作成
- リポジトリインターフェースの定義
- ユースケースの作成
- リポジトリの実装
- コントローラーの実装
- 依存性注入の設定
DDDの基本概念
DDDでは、アプリケーションを以下の4つの層に分けて考えます。
├── ドメイン層 (Domain Layer)
├── アプリケーション層 (Application Layer)
├── インフラ層 (Infrastructure Layer)
└── プレゼンテーション層 (Presentation Layer)
ドメイン層とは
ドメイン層は、アプリケーションが扱う業務領域の概念とルールを表現する層です。
今回のユーザー管理システムでは、「ユーザーとは何か」「メールアドレスはどのような形式か」「ユーザー名はどのような制約があるか」といった業務上のルールを定義します。
この層には外部システム(データベースやWebフレームワーク)への依存がありません。
値オブジェクトの作成
値オブジェクト(Value Object) とは
値オブジェクト(Value Object) は、ドメインにおける特定の概念や属性を表現するオブジェクトです。値そのものに意味とルールを持たせることで、コードの安全性と表現力を高めます。
| 特徴 | 説明 | メリット |
|---|---|---|
| 識別子を持たない | エンティティとは異なり、データベースIDなどの一意の識別子(Identity)を持ちません。 | 永続化を考えず、純粋なビジネスルールに集中できます。 |
| 不変性(Immutability) | 一度生成されたら、その値を変更することはできません。変更が必要な場合は、新しい値オブジェクトを生成します。 | マルチスレッド環境での安全性が向上し、予期せぬ変更バグを防げます。 |
| 等価性による比較 | フィールドに含まれるすべての値が等しい場合、それらは同じオブジェクトとみなされます。 | メールアドレスAとメールアドレスBの値が完全に一致すれば、同じメールアドレスとして扱われます。 |
| ドメインルールの内包 | その値が常に正しい状態であるためのバリデーションやロジックを自身のコンストラクタやメソッド内に持ちます。 | 不正な値を持つオブジェクトは存在できなくなります(コンストラクタでエラーになるため)。 |
コンストラクタ(Constructor)とは
コンストラクタとは、クラス(設計図)から新しいオブジェクト(実体)を生成する際に、自動的に呼び出される特別なメソッドです。その主な役割は、生成されたばかりのオブジェクトの初期状態を正しい値で設定することです。
メールアドレスを表す値オブジェクトの作成
まずメールアドレスを表す値オブジェクトを作成します。
export class Email {
private readonly value: string;
constructor(value: string) {
if (!value) {
throw new Error('Email is required');
}
this.value = value;
}
getValue(): string {
return this.value;
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
今回重要になるのは、バリデーションの役割分担です。
元のコードではZodを使ってバリデーションを行っていました。
export const createUserSchema = z.object({
email: z.string().email({ message: "Invalid email format" }),
name: z.string().min(1).max(100),
});
DDDにおいて、バリデーションには2つの異なる役割があります。
役割1. 入力値の形式チェック(プレゼンテーション層)
HTTPリクエストから受け取った値が正しい形式かをチェックします。これはZodの役割として残します。
app.post(
'/users',
validate(z.object({ body: createUserSchema })), // Zodによる形式チェック
...
);
この形式チェックは引き続きZodに任せます。メールアドレスの正規表現チェックや文字数制限などは、Zodで実施済みです。
役割2. ドメイン概念の表現(ドメイン層)
Zodでメールアドレス形式チェックは完了しているため、値オブジェクトでは最小限のチェック(空文字チェック)のみ行うようにしています。
値オブジェクトを利用することで以下のメリットがあります。
メリット1. 型安全性の提供(stringではなくEmail型として扱える)
元のコードでは email は単なる string 型でした。これでは以下のような問題が起こります。
// Before: すべてstring型
const email: string = "test@example.com";
const name: string = "John Doe";
// 間違えてnameをemailの引数に渡してもエラーにならない
someFunction(name); // 本来はemailを渡すべきだが、コンパイルエラーにならない
値オブジェクトを使うと、型システムが間違いを検出してくれます。
// After: 専用の型
const email = new Email("test@example.com");
const name = new UserName("John Doe");
// 型が違うのでコンパイルエラーになる
someFunction(name); // エラー: Email型が必要だがUserName型が渡されている
メリット2. ドメイン操作の提供(比較、変換など)
値オブジェクトは、その値に関する操作をカプセル化できます。
const email1 = new Email("test@example.com");
const email2 = new Email("test@example.com");
// 値の比較が明確になる
console.log(email1.equals(email2)); // true
// もし将来、メールアドレスの正規化が必要になったら
// Email クラス内に実装を追加するだけ
メリット3. ドメイン概念の明確化
コードを読んだときに、「これはただの文字列ではなく、メールアドレスという概念だ」とすぐにわかります。
// Before: 何の文字列かわかりにくい
function updateUser(userId: number, email: string, name: string) {
// emailとnameが逆になっていても気づきにくい
}
// After: 型名から何を表すかが明確
function updateUser(userId: UserId, email: Email, name: UserName) {
// 型が違うので間違いに気づきやすい
}
ユーザー名を表す値オブジェクトの作成
同様にユーザー名の値オブジェクトも作成します。
export class UserName {
private readonly value: string;
constructor(value: string) {
if (!value || value.length < 1) {
throw new Error('Name must be at least 1 character');
}
if (value.length > 100) {
throw new Error('Name must be at most 100 characters');
}
this.value = value;
}
getValue(): string {
return this.value;
}
equals(other: UserName): boolean {
return this.value === other.value;
}
}
ユーザーIDを表す値オブジェクトの作成
ユーザーIDの値オブジェクトも作成します。
export class UserId {
private readonly value: number;
constructor(value: number) {
if (value <= 0) {
throw new Error('User ID must be a positive number');
}
this.value = value;
}
getValue(): number {
return this.value;
}
equals(other: UserId): boolean {
return this.value === other.value;
}
}
まとめ
今回は、リファクタリングの前提条件とDDDにおける値オブジェクト(Value Object) について説明しました。
- ポイント
- Zodによる形式チェックはプレゼンテーション層で継続
- 値オブジェクトは型安全性とドメイン概念の表現が主な役割
- バリデーションの重複を避けながら、DDDのメリットを享受
- メリット
- 型安全性が向上し、間違いをコンパイル時に検出できる
- ドメイン操作を一箇所にまとめられる
- コードの意図が明確になる
次回は、エンティティ(Entity) の作成について説明します。