はじめに
この記事は、以下の記事の続きです。
今回はユースケースの作成について説明します。
開発環境
開発環境は以下の通りです。
- 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
インフラ層での実装
Part3でリポジトリのインターフェースを定義しました。今回は、そのインターフェースをPrismaを使って具体的に実装します。
export interface IUserRepository {
save(user: User): Promise<User>;
findById(id: UserId): Promise<User | null>;
findAll(): Promise<User[]>;
update(user: User): Promise<User>;
delete(id: UserId): Promise<void>;
existsByEmail(email: Email): Promise<boolean>;
}
インフラ層の役割
インフラ層は、技術的な実装の詳細を担当する層です。データベース、外部API、ファイルシステムなど、具体的な技術に依存する処理をここに配置します。
ドメイン層で定義したインターフェースを、この層で具体的に実装することで、ドメイン層を技術的な詳細から保護します。
リポジトリの実装
Prismaを使ってリポジトリを実装します。
import { PrismaClient } from '@prisma/client';
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async save(user: User): Promise<User> {
const data = await this.prisma.user.create({
data: {
email: user.getEmail().getValue(),
name: user.getName().getValue(),
},
});
return User.reconstruct(
new UserId(data.id),
new Email(data.email),
new UserName(data.name)
);
}
async findById(id: UserId): Promise<User | null> {
const data = await this.prisma.user.findUnique({
where: { id: id.getValue() },
});
if (!data) {
return null;
}
return User.reconstruct(
new UserId(data.id),
new Email(data.email),
new UserName(data.name)
);
}
async findAll(): Promise<User[]> {
const users = await this.prisma.user.findMany();
return users.map(data =>
User.reconstruct(
new UserId(data.id),
new Email(data.email),
new UserName(data.name)
)
);
}
async update(user: User): Promise<User> {
const data = await this.prisma.user.update({
where: { id: user.getId().getValue() },
data: {
email: user.getEmail().getValue(),
name: user.getName().getValue(),
},
});
return User.reconstruct(
new UserId(data.id),
new Email(data.email),
new UserName(data.name)
);
}
async delete(id: UserId): Promise<void> {
await this.prisma.user.delete({
where: { id: id.getValue() },
});
}
async existsByEmail(email: Email): Promise<boolean> {
const count = await this.prisma.user.count({
where: { email: email.getValue() },
});
return count > 0;
}
}
ポイント1. ドメインオブジェクトとDBモデルの変換
リポジトリの重要な役割の1つは、ドメインオブジェクトとデータベースモデルの変換です。
保存時: ドメインオブジェクト → DBモデル
async save(user: User): Promise<User> {
// ドメインオブジェクトから値を取り出す
const data = await this.prisma.user.create({
data: {
email: user.getEmail().getValue(), // Email値オブジェクト → string
name: user.getName().getValue(), // UserName値オブジェクト → string
},
});
// ...
}
ドメインオブジェクトは値オブジェクトを持っていますが、データベースにはプリミティブ型で保存します。
取得時: DBモデル → ドメインオブジェクト
async findById(id: UserId): Promise<User | null> {
const data = await this.prisma.user.findUnique({
where: { id: id.getValue() },
});
if (!data) {
return null;
}
// DBから取得したプリミティブ型をドメインオブジェクトに変換
return User.reconstruct(
new UserId(data.id), // number → UserId値オブジェクト
new Email(data.email), // string → Email値オブジェクト
new UserName(data.name) // string → UserName値オブジェクト
);
}
データベースから取得したプリミティブ型のデータを、値オブジェクトに変換してエンティティを再構築します。
なぜ変換が必要なのか:
- ドメイン層では型安全な値オブジェクトを使いたい
- データベースはプリミティブ型で保存する
- この変換はインフラ層の責務
ポイント2. Prismaへの依存はこの層だけ
元のコードでは、コントローラーから直接Prismaを呼び出していました。
// Before: Prismaへの依存がコントローラーに漏れている
app.post("/users", async (req, res) => {
const user = await prisma.user.create({
data: { email, name },
});
res.status(201).json(user);
});
リポジトリパターンを使うことで、Prismaへの依存をインフラ層に閉じ込められます。
// After: Prismaへの依存はリポジトリだけ
export class PrismaUserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
// Prismaを使った実装
}
メリット:
- 将来的にPrismaから別のORM(TypeORM、Drizzleなど)に変更しやすい
- リポジトリの実装を差し替えるだけで、他の層に影響を与えない
- ドメイン層とアプリケーション層がデータベースの詳細を知らなくて済む
ポイント3. エラーハンドリング
リポジトリでは、データベース固有のエラーをドメイン固有のエラーに変換することもできます。
async save(user: User): Promise<User> {
try {
const data = await this.prisma.user.create({
data: {
email: user.getEmail().getValue(),
name: user.getName().getValue(),
},
});
return User.reconstruct(
new UserId(data.id),
new Email(data.email),
new UserName(data.name)
);
} catch (error) {
// Prisma固有のエラーをドメインのエラーに変換
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new Error('Email already exists');
}
}
throw error;
}
}
ただし、今回のサンプルでは簡潔さのためエラーハンドリングは省略しています。
ポイント4. 一貫性のあるインターフェース
リポジトリのメソッドは、常にドメインオブジェクトを受け取り、ドメインオブジェクトを返します。
// 受け取る: ドメインオブジェクト
async save(user: User): Promise<User>
async findById(id: UserId): Promise<User | null>
async update(user: User): Promise<User>
async delete(id: UserId): Promise<void>
// プリミティブ型は受け取らない(NG)
async findById(id: number): Promise<User | null> // NG
メリット:
- 使う側(ユースケース)が扱いやすい
- 型安全性が保たれる
- ドメインの概念が一貫している
まとめ
今回は、DDDにおけるリポジトリ(Repository) の実装について説明しました。
- ポイント
- リポジトリはドメインオブジェクトとDBモデルを変換する
- データベース固有の技術への依存をインフラ層に閉じ込める
- インターフェースを実装することで、実装を差し替え可能にする
- ドメインオブジェクトを受け取り、ドメインオブジェクトを返す
- メリット
- データベースの実装詳細がドメイン層に漏れない
- ORMの変更が容易
- テスト用のモックを作りやすい
- ドメイン層とアプリケーション層がシンプルになる
次回は、コントローラーの実装について説明します。