はじめに
この記事は「リファクタリングを通して学ぶドメイン駆動設計」シリーズのPart6です。
今回はコントローラーの実装について解説します。
開発環境
開発環境は以下の通りです。
- 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
プレゼンテーション層での実装
コントローラーはプレゼンテーション層で実装します。ビジネスロジックは持たず、HTTPリクエスト/レスポンスの変換のみを行います。
プレゼンテーション層の役割
プレゼンテーション層は、外部からのリクエストを受け取り、アプリケーション層(ユースケース)を呼び出し、レスポンスを返します。
この層の責務はHTTPの詳細を扱うことであり、ビジネスロジックは含みません。
| 責務 | 説明 |
|---|---|
| リクエストの受け取り | HTTPリクエストからデータを取り出す |
| バリデーション | Zodなどで入力値の形式をチェック |
| ユースケースの呼び出し | アプリケーション層に処理を委譲 |
| レスポンスの返却 | ユースケースの結果をHTTPレスポンスに変換 |
| エラーハンドリング | 例外をHTTPステータスコードとメッセージに変換 |
コントローラーの実装
プレゼンテーション層では、HTTPリクエストを受け取り、ユースケースを呼び出します。
import { Request, Response } from "express";
import { CreateUserUseCase } from "../../application/usecases/CreateUserUseCase";
import { DeleteUserUseCase } from "../../application/usecases/DeleteUserUseCase";
import { GetAllUsersUseCase } from "../../application/usecases/GetAllUsersUseCase";
import { GetUserUseCase } from "../../application/usecases/GetUserUseCase";
import { UpdateUserUseCase } from "../../application/usecases/UpdateUserUseCase";
import {
CreateUserInput,
UpdateUserInput,
UserIdParam,
} from "../../schemas/user.schema";
export class UserController {
constructor(
private readonly createUserUseCase: CreateUserUseCase,
private readonly getUserUseCase: GetUserUseCase,
private readonly getAllUsersUseCase: GetAllUsersUseCase,
private readonly updateUserUseCase: UpdateUserUseCase,
private readonly deleteUserUseCase: DeleteUserUseCase
) {}
async create(
req: Request<{}, {}, CreateUserInput>,
res: Response
): Promise<void> {
try {
const { email, name } = req.body;
const result = await this.createUserUseCase.execute({ email, name });
res.status(201).json(result);
} catch (error) {
if (error instanceof Error) {
res.status(400).json({ error: error.message });
}
res.status(500).json({ error: "Failed to create user" });
}
}
async getAll(_: Request, res: Response): Promise<void> {
try {
const result = await this.getAllUsersUseCase.execute();
res.json(result);
} catch (error) {
res.status(500).json({ error: "Failed to fetch users" });
}
}
async getById(req: Request<UserIdParam>, res: Response): Promise<void> {
try {
const { id } = req.params;
const result = await this.getUserUseCase.execute({ id: Number(id) });
if (!result) {
res.status(404).json({ error: "User not found" });
return;
}
res.json(result);
} catch (error) {
if (error instanceof Error) {
res.status(400).json({ error: error.message });
}
res.status(500).json({ error: "Failed to fetch user" });
}
}
async update(
req: Request<UserIdParam, {}, UpdateUserInput>,
res: Response
): Promise<void> {
try {
const { id } = req.params;
const { email, name } = req.body;
const result = await this.updateUserUseCase.execute({
id: Number(id),
email,
name,
});
res.json(result);
} catch (error) {
if (error instanceof Error) {
res.status(400).json({ error: error.message });
}
res.status(500).json({ error: "Failed to update user" });
}
}
async delete(req: Request<UserIdParam>, res: Response): Promise<void> {
try {
const { id } = req.params;
await this.deleteUserUseCase.execute({ id: Number(id) });
res.status(204).send();
} catch (error) {
if (error instanceof Error) {
res.status(400).json({ error: error.message });
}
res.status(500).json({ error: "Failed to delete user" });
}
}
}
ポイント1. 薄いコントローラー
コントローラーは非常にシンプルな構造になっています。
async create(
req: Request<{}, {}, CreateUserInput>,
res: Response
): Promise<void> {
try {
// 1. リクエストからデータを取り出す
const { email, name } = req.body;
// 2. ユースケースを呼び出す
const result = await this.createUserUseCase.execute({ email, name });
// 3. レスポンスを返す
res.status(201).json(result);
} catch (error) {
// 4. エラーハンドリング
if (error instanceof Error) {
res.status(400).json({ error: error.message });
}
res.status(500).json({ error: "Failed to create user" });
}
}
コントローラーが持つべきでないもの:
- ビジネスロジック(ユースケースに任せる)
- データベース操作(リポジトリに任せる)
- 複雑な条件分岐(ドメイン層に任せる)
- バリデーションロジック(Zodミドルウェアに任せる)
コントローラーが持つべきもの:
- HTTPリクエストの解析
- ユースケースの呼び出し
- HTTPレスポンスの生成
- HTTPステータスコードの決定
- エラーのHTTPレスポンスへの変換
ポイント2. エラーハンドリング
コントローラーでは、ドメイン層やアプリケーション層で発生したエラーを、適切なHTTPステータスコードとメッセージに変換します。
try {
const result = await this.createUserUseCase.execute({ email, name });
res.status(201).json(result);
} catch (error) {
if (error instanceof Error) {
// ビジネスロジックのエラー(例: Email already exists)
res.status(400).json({ error: error.message });
}
// 予期しないエラー
res.status(500).json({ error: "Failed to create user" });
}
ポイント3. ユースケースへの依存
コントローラーは、コンストラクタでユースケースを受け取ります。
export class UserController {
constructor(
private readonly createUserUseCase: CreateUserUseCase,
private readonly getUserUseCase: GetUserUseCase,
private readonly getAllUsersUseCase: GetAllUsersUseCase,
private readonly updateUserUseCase: UpdateUserUseCase,
private readonly deleteUserUseCase: DeleteUserUseCase
) {}
}
これにより、以下のメリットがあります。
メリット1. テストが容易
// テスト時はモックユースケースを注入できる
const mockCreateUserUseCase = {
execute: jest.fn().mockResolvedValue({
id: 1,
email: 'test@example.com',
name: 'Test User',
}),
};
const controller = new UserController(
mockCreateUserUseCase as any,
// ... 他のモック
);
メリット2. ビジネスロジックの分離
コントローラーはHTTPの詳細だけを扱い、ビジネスロジックはユースケースに委譲されています。
ルーティングの設定
ルーティング設定を行います。
import express, { Request, Response } from "express";
import z from "zod";
import { validate } from "../../middlewares/validate.middleware";
import {
CreateUserInput,
createUserSchema,
UpdateUserInput,
updateUserSchema,
UserIdParam,
userIdSchema,
} from "../../schemas/user.schema";
import { UserController } from "../controllers/UserController";
export function setupUserRoutes(
router: express.Router,
userController: UserController
) {
// ユーザー作成
router.post(
"/users",
validate(z.object({ body: createUserSchema })),
(req: Request<{}, {}, CreateUserInput>, res: Response) =>
userController.create(req, res)
);
// 全ユーザー取得
router.get("/users", (req: Request, res: Response) =>
userController.getAll(req, res)
);
// 特定ユーザー取得
router.get(
"/users/:id",
validate(z.object({ params: userIdSchema })),
(req: Request<UserIdParam>, res: Response) =>
userController.getById(req, res)
);
// ユーザー更新
router.put(
"/users/:id",
validate(
z.object({
params: userIdSchema,
body: updateUserSchema,
})
),
(req: Request<UserIdParam, {}, UpdateUserInput>, res: Response) =>
userController.update(req, res)
);
// ユーザー削除
router.delete(
"/users/:id",
validate(z.object({ params: userIdSchema })),
(req: Request<UserIdParam>, res: Response) =>
userController.delete(req, res)
);
}
ルーティング設定のポイント
-
Zodバリデーション:
validateミドルウェアでリクエストの形式をチェック -
型定義:
Request<UserIdParam, {}, CreateUserInput>のように明示的に指定 - シンプルな構造: コントローラーのメソッドを呼び出すだけ
これにより、元のコードにあったバリデーション機能と型安全性を維持しています。
まとめ
今回は、DDDにおけるコントローラー(Controller) の実装について説明しました。
- ポイント
- コントローラーは薄く保ち、HTTPの詳細のみを扱う
- ビジネスロジックはユースケースに委譲する
- エラーを適切なHTTPステータスコードに変換する
- メリット
- ビジネスロジックとHTTPの詳細が分離される
- テストが容易になる
- 各層の責務が明確になる
次回は、依存性注入 について説明します。これまで作成してきた各層を組み立て、実際に動作するアプリケーションを完成させます。