ソフトウェア開発において、プロジェクトの初期段階で立てた仕様が未来永劫変わらない、ということはまずあり得ないですよね?
ビジネスの成長や技術の進化に伴い、「データベースを別のものに差し替えたい」「既存のロジックを再利用して、Web APIだけでなくCLIツールも提供したい」といった要求は、ごく自然に発生するでしょう...
しかし、これらの「当たり前」のはずの要求が、なぜしばしば大規模で困難なコード修正を伴うのだろうか?
その原因の多くは、アプリケーションの「構造」、すなわちアーキテクチャに潜んでいると思います...
ビジネスの核心的なルールを担うコードが、データベース操作や特定フレームワークの作法といった技術的な詳細と密接に絡み合っている場合、一部の変更がシステム全体に波及してしまうことはよくある経験は多いのではないでしょうか?
本稿は、こうした課題を克服し、変更に強く、保守性の高いシステムを構築するため,代表的なアーキテクチャであるMVC、レイヤード、そしてクリーンアーキテクチャを比較・解説し、特に将来の変更に柔軟に対応できる「クリーンアーキテクチャ」の構築方法を説明していきます.
1. はじめに:使用する技術スタック
-
Bun: 高速なJavaScriptランタイム、バンドラ、テストランナー、パッケージマネージャを兼ね備えたオールインワンツールキットである。
-
Hono.js:
高速かつ軽量なWebフレームワークである。Cloudflare Workersのようなエッジ環境からNode.js、そしてBunまで、様々なJavaScriptランタイムで動作する。シンプルで使いやすいAPIを提供する。
-
Zod:
TypeScriptファーストのスキーマ宣言および検証ライブラリである。静的なTypeScriptの型定義から、実行時のバリデーターを推論できるため、データの整合性を安全かつ容易に保つことができる。
-
MySQL & Redis:
リレーショナルデータベースとインメモリキーバリューストアの代表格であり、永続化とキャッシングの役割を担う。
-
Prisma:
次世代のNode.jsおよびTypeScript向けORM(Object-Relational Mapper)である。schema.prisma
というスキーマファイルでデータモデルを定義すると、型安全なデータベースアクセスクライアントが自動生成される。これにより、手書きのSQLを減らし、開発効率と安全性を向上させる。
-
uuid:
-
Docker & Docker Compose: コンテナ技術を用いて、開発環境と本番環境の差異をなくし、ポータブルなアプリケーション実行環境を構築する。
-
ESLint: コード品質を維持するためのリンターであり,flatconfig(
eslint.config.js
) 形式で設定する。
Bun, Hono, Prisma ORMを使い、3つの異なるアーキテクチャでMySQLおよびRedisと通信するREST APIをDockerで構築する方法を、詳細な説明とコード例を交えて包括的に解説する。
2. 環境構築
まず、全てのアーキテクチャで共通となる開発環境を構築する。
docker, bunは以下のように導入する
Dockerの導入
macOS:
# Homebrewを使用してDockerをインストール
brew install --cask docker
Windows:
公式サイトからDocker Desktopをダウンロードしてインストール
https://www.docker.com/products/docker-desktop/
Bunの導入
macOS/Linux:
# curlを使用してインストール
# bashの場合
curl -fsSL https://bun.sh/install | bash
exec /bin/bash
# zshの場合
curl -fsSL https://bun.sh/install | zsh
exec /bin/zsh
Windows:
# PowerShellで実行
powershell -c "irm bun.sh/install.ps1 | iex"
# または、npmを使用してインストール
npm install -g bun
インストール後、以下のコマンドでバージョンを確認できます:
docker --version
bun --version
2.1. ディレクトリとファイルの作成
本記事ではいくつかのアーキテクチャを紹介しますが,ここからは一旦共通で使うディレクトリをセットアップしていきます.
# プロジェクトのルートディレクトリを作成
mkdir hono-backend-architecture-guide
cd hono-backend-architecture-guide
# Dockerと設定ファイルを先に作成
touch Dockerfile
touch docker-compose.yml
touch tsconfig.json
touch eslint.config.js
2.2. BunおよびPrismaプロジェクトの初期化
# Bunプロジェクトを初期化し、package.jsonを生成
bun init -y
# Prismaのセットアップコマンド。prisma/schema.prisma と .env ファイルが生成される
bunx prisma init --datasource-provider mysql
bunxとは?
bunxはBunのパッケージマネージャであるBunxを使用して、コマンドラインからパッケージを実行するためのツール
2.3. 依存ライブラリのインストール
bun addを用いることで、package.jsonに依存ライブラリを追加できる
# アプリケーションの依存ライブラリをインストール
# Prisma ClientはPrismaが生成するORMクライアント
bun add hono @hono/node-server @hono/zod-validator zod redis uuid @prisma/client
# 開発用の依存ライブラリをインストール
# Prisma CLI、TypeScriptと各種型定義、ESLint
bun add -d prisma typescript @types/node @types/uuid eslint @eslint/js typescript-eslint
2.4. 設定ファイルの記述
.env
prisma init
で生成された.env
ファイルを編集し、Docker Compose環境のデータベースURLを設定する。
DATABASE_URL="mysql://root:rootpassword@db:3306/testdb"
paclage.json
バージョンは任意のものをご利用ください
{
"name": "hono-backend-architecture-guide",
"private": true,
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun run src/index.ts"
},
"devDependencies": {
"@eslint/js": "^9.27.0",
"@types/bun": "latest",
"@types/node": "^22.15.21",
"@types/uuid": "^10.0.0",
"eslint": "^9.27.0",
"prisma": "^6.8.2",
"typescript-eslint": "^8.32.1"
},
"peerDependencies": {
"typescript": "^5.8.3"
},
"dependencies": {
"@hono/node-server": "^1.14.2",
"@hono/zod-validator": "^0.5.0",
"@prisma/client": "^6.8.2",
"hono": "^4.7.10",
"redis": "^5.1.0",
"uuid": "^11.1.0",
"zod": "^3.25.28"
}
}
prisma/schema.prisma
データベースのテーブル構造を定義するファイル。今回はuserテーブルを作成する。userテーブルにはid, email, nameの3つのカラムを作成する。
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-arm64-openssl-3.0.x"] // Dockerコンテナ(Linux環境)で動作させるためLinux ARM64環境でOpenSSL 1.1.x用の現在のマシン(開発環境)用のバイナリを生成
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
name String
@@map("users") // データベース上のテーブル名を'users'に指定
}
tsconfig.json
TypeScriptコンパイラの設定ファイル。
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"types": ["bun-types"]
},
"include": ["src"]
}
eslint.config.js
ESLintの設定ファイル。
Eslintとは?
EslintはJavaScriptやTypeScriptのコードを静的解析し、コードの品質を維持するためのツールです。
例えば、以下のようなコードはEslintでエラーとなります。
// 未使用の変数(no-unused-vars)
const unusedVariable = 'this is not used';
// セミコロンの不一致(semi)
const message = 'Hello World'
// 未定義の変数の使用(no-undef)
console.log(undefinedVariable);
// 等価演算子の使用(eqeqeq)
if (value == null) {
// === を使うべき
}
これらのエラーを事前に検出することで、バグの発生を防ぎ、コードの品質を向上させることができる
ここでは、eslint.config.jsを作成します。
import globals from "globals";
import tseslint from "typescript-eslint";
export default [
{ files: ["**/*.{js,mjs,cjs,ts}"] },
{ languageOptions: { globals: { ...globals.browser, ...globals.node } } },
...tseslint.configs.recommended,
];
この設定では以下のことを行っています:
-
対象ファイルの指定 (
files
):-
**/*.{js,mjs,cjs,ts}
: プロジェクト内のすべてのJavaScript(.js, .mjs, .cjs)とTypeScript(.ts)ファイルを対象とする
-
-
グローバル変数の設定 (
languageOptions.globals
):-
globals.browser
: ブラウザ環境のグローバル変数(window
,document
,console
など)を認識 -
globals.node
: Node.js環境のグローバル変数(process
,Buffer
,__dirname
など)を認識 - これにより、これらの変数を使用してもESLintが「未定義の変数」エラーを出さなくなる
-
-
TypeScript推奨ルールの適用 (
tseslint.configs.recommended
):- TypeScript用の推奨ルールセットを適用
- 型安全性を高めるルール、TypeScript特有の構文チェックなどが含まれる
- 例:
@typescript-eslint/no-unused-vars
(未使用の変数を検出),@typescript-eslint/no-explicit-any
(any型を検出)など
Dockerfile
アプリケーションをコンテナ化するための設計図。
FROM oven/bun:latest
WORKDIR /app
# OpenSSLをインストール(Prismaの警告を解決)
RUN apt-get update -y && apt-get install -y openssl
# 最初に依存関係ファイルのみをコピー (bun.lockb を使用)
COPY package.json bun.lock ./
# Prismaのスキーマファイルも先にコピー
COPY prisma ./prisma/
# 依存関係をインストール (frozen-lockfile を使用)
RUN bun install --frozen-lockfile
# アプリケーションのソースコードをコピー
COPY . .
# ★ Prisma Clientを生成する
RUN bunx prisma generate
# アプリケーションがリッスンするポートを公開
EXPOSE 3000
# アプリケーションを起動
CMD ["bun", "run", "src/index.ts"]
docker-compose.yml
複数コンテナを管理するファイル。
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
environment:
# .envファイルからDATABASE_URLを読み込む
- DATABASE_URL=${DATABASE_URL}
- REDIS_HOST=redis
- REDIS_PORT=6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
tty: true
stdin_open: true
db:
image: mysql:8.0
# .envファイルから直接環境変数を読み込むことも可能
env_file:
- .env
environment:
MYSQL_ROOT_PASSWORD: rootpassword
# docker-compose.yml内の環境変数は.envファイルの値より優先される場合があるため注意
MYSQL_DATABASE: testdb
MYSQL_USER: root
MYSQL_PASSWORD: rootpassword
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7.0
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql_data:
redis_data:
これでコンテナのセットアップは完了です.次はアプリケーションのコードを書いていきます!
番外編:アーキテクチャがないとどうなる?
アーキテクチャがないと、コードが肥大化してしまい、保守性が低下します。
例えば以下のように1つのindex.tsファイルに全てのコードを書いた場合...
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { PrismaClient } from '@prisma/client';
import { createClient } from 'redis';
import { v4 as uuidv4 } from 'uuid';
const app = new Hono();
const prisma = new PrismaClient();
const redis = createClient({ url: 'redis://localhost:6379' });
// ユーザー作成API
app.post('/users', async (c) => {
try {
const { name, email } = await c.req.json();
// バリデーション(ここに直接書く)
if (!name || !email) {
return c.json({ error: 'Name and email are required' }, 400);
}
if (!email.includes('@')) {
return c.json({ error: 'Invalid email format' }, 400);
}
// データベース操作(ここに直接書く)
const existingUser = await prisma.user.findUnique({
where: { email }
});
if (existingUser) {
return c.json({ error: 'Email already exists' }, 409);
}
// ユーザー作成
const newUser = await prisma.user.create({
data: {
id: uuidv4(),
name,
email
}
});
// キャッシュに保存(ここに直接書く)
await redis.set(`user:${newUser.id}`, JSON.stringify(newUser));
// ログ出力(ここに直接書く)
console.log(`User created: ${newUser.id}`);
return c.json(newUser, 201);
} catch (error) {
console.error('Error creating user:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ユーザー取得API
app.get('/users/:id', async (c) => {
try {
const id = c.req.param('id');
// キャッシュから取得を試す(ここに直接書く)
const cached = await redis.get(`user:${id}`);
if (cached) {
console.log(`Cache hit for user: ${id}`);
return c.json(JSON.parse(cached));
}
// データベースから取得(ここに直接書く)
const user = await prisma.user.findUnique({
where: { id }
});
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
// キャッシュに保存(ここに直接書く)
await redis.set(`user:${id}`, JSON.stringify(user));
console.log(`Database hit for user: ${id}`);
return c.json(user);
} catch (error) {
console.error('Error fetching user:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// ユーザー更新API
app.put('/users/:id', async (c) => {
try {
const id = c.req.param('id');
const { name, email } = await c.req.json();
// バリデーション(同じコードを再度書く)
if (!name || !email) {
return c.json({ error: 'Name and email are required' }, 400);
}
if (!email.includes('@')) {
return c.json({ error: 'Invalid email format' }, 400);
}
// 存在チェック
const existingUser = await prisma.user.findUnique({
where: { id }
});
if (!existingUser) {
return c.json({ error: 'User not found' }, 404);
}
// メール重複チェック(他のユーザーとの重複)
const emailConflict = await prisma.user.findFirst({
where: {
email,
id: { not: id }
}
});
if (emailConflict) {
return c.json({ error: 'Email already exists' }, 409);
}
// ユーザー更新
const updatedUser = await prisma.user.update({
where: { id },
data: { name, email }
});
// キャッシュを更新(ここに直接書く)
await redis.set(`user:${id}`, JSON.stringify(updatedUser));
console.log(`User updated: ${id}`);
return c.json(updatedUser);
} catch (error) {
console.error('Error updating user:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// サーバー起動
serve(app, (info) => {
console.log(`Server running on http://localhost:${info.port}`);
});
このコードの問題点
1. 責務の混在
- HTTPハンドリング、バリデーション、データベース操作、キャッシュ操作、ログ出力が全て混在
- 1つの関数が複数の責務を持っている
2. コードの重複
- バリデーションロジックが複数箇所に重複
- キャッシュ操作のコードが重複
- エラーハンドリングが重複
3. テストの困難さ
- データベースやRedisに依存するため、単体テストが困難
- モックやスタブを作成するのが複雑
- 特定の機能だけをテストできない
4. 変更の影響範囲が広い
- データベースをMySQLからPostgreSQLに変更する場合、全てのAPIエンドポイントを修正が必要
- バリデーションルールを変更する場合、複数箇所を修正が必要
- キャッシュ戦略を変更する場合、全てのエンドポイントに影響
5. 再利用性の欠如
- ユーザー作成ロジックをCLIツールで再利用できない
- 他のプロジェクトでユーザー管理機能を再利用できない
6. 保守性の低下
- ファイルが巨大になり、理解が困難
- 新しい機能追加時に既存コードへの影響を把握しにくい
- バグ修正時に副作用を起こしやすい
7. チーム開発の困難さ
- 複数人で同じファイルを編集するとコンフリクトが発生しやすい
- 機能ごとに担当を分けることができない
アーキテクチャを導入することで解決される問題
これらの問題は、適切なアーキテクチャ(MVC、レイヤード、クリーンアーキテクチャ)を導入することで解決できます:
- 責務の分離: 各層が明確な責務を持つ
- 再利用性: ビジネスロジックを複数の場所で再利用可能
- テスト容易性: 各層を独立してテスト可能
- 保守性: 変更の影響範囲を限定できる
- チーム開発: 機能ごとにファイルを分割し、並行開発が可能
次のセクションから、これらの問題を解決するアーキテクチャパターンを詳しく見ていきましょう。
3. MVCアーキテクチャ
MVCアーキテクチャとは,責務をModel, View, Controllerの3つに分割する、シンプルで伝統的なアーキテクチャです.
3.1. 説明と構造
-
Model:
アプリケーションのデータと、それに関連するビジネスロジックを担う。Prisma Clientを直接利用してデータベースと通信する。 -
View:
REST APIにおいては、JSONレスポンスがViewに相当する。 -
Controller:
HTTPリクエストを受け取り、Modelを呼び出してデータを操作し、その結果をViewに渡してレスポンスを生成する。
利点:
-
関心事の分離:
各コンポーネントが独立して開発・テストできる。 - 再利用性: Modelは複数のViewで再利用可能。
欠点:
- Controllerが肥大化しやすい(Fat Controller)。
- ModelとViewの間の依存関係が複雑になることがある。
3.2. ディレクトリ構造と作成コマンド
.
├── prisma
│ └── schema.prisma
└── src
├── controllers
│ └── userController.ts
├── lib
│ └── prismaClient.ts
├── models
│ └── userModel.ts
├── routes
│ └── userRoutes.ts
├── views
│ └── userView.ts
└── index.ts
# ディレクトリを作成
mkdir -p src/controllers src/lib src/models src/routes src/views
# ファイルを作成
touch src/controllers/userController.ts src/lib/prismaClient.ts src/models/userModel.ts src/routes/userRoutes.ts src/views/userView.ts src/index.ts
3.3. コード例
src/lib/prismaClient.ts
Prisma Clientのインスタンスをシングルトンとして生成し、エクスポートする。
つまり,このファイルではPrisma Clientのインスタンスを生成し、他のファイルではこのインスタンスを利用することで、Prisma Clientのインスタンスを共有することができます
import { PrismaClient } from '@prisma/client';
// アプリケーション全体で共有するPrisma Clientの単一インスタンス
export const prisma = new PrismaClient();
src/models/userModel.ts
ModelはPrisma Clientを直接利用してデータ操作を行う。
つまり,Modelはデータベースと直接やり取りすることができます。
import { prisma } from '../lib/prismaClient';
import { User } from '@prisma/client';
// Prismaが生成したUser型を、このModelで扱うデータ型としてエクスポート
export type UserType = User;
export class UserModel {
/**
* IDによって単一のユーザーを検索する
* @param id ユーザーID
* @returns ユーザーオブジェクト or null
*/
public async findById(id: string): Promise<UserType | null> {
// データアクセスロジックはModelの内部に隠蔽される
return prisma.user.findUnique({
where: { id },
});
}
/**
* 新しいユーザーを作成し、永続化する
* @param data { name: string, email: string }
* @returns 作成されたユーザーオブジェクト
*/
public async createUser(data: { name: string, email: string }): Promise<UserType> {
// 「ユーザーを作成する」というビジネスロジックをここに集約
// Controllerは、nameとemailを渡すだけでよい
const newUser = await prisma.user.create({
data: {
name: data.name,
email: data.email,
},
});
return newUser;
}
}
src/views/userView.ts
Viewは、Controllerから渡されたデータをもとに、クライアントに返すレスポンスを生成することに特化します。
つまり,Viewはデータを受け取り,それをクライアントに返すことができます。
import { Context } from 'hono';
import { UserType } from '../models/userModel';
export class UserView {
/**
* 成功時のユーザー情報をJSONで返す
* @param c HonoのContext
* @param user 表示するユーザーデータ
* @param statusCode HTTPステータスコード (デフォルトは200)
*/
public renderUser(c: Context, user: UserType, statusCode: number = 200): Response {
return c.json(user, statusCode);
}
/**
* ユーザーが見つからなかった場合のエラーレスポンスを返す
* @param c HonoのContext
*/
public renderNotFound(c: Context): Response {
return c.json({ message: 'User not found' }, 404);
}
/**
* バリデーションエラーなどのクライアントエラーレスポンスを返す
* @param c HonoのContext
* @param message エラーメッセージ
*/
public renderBadRequest(c: Context, message: string): Response {
return c.json({ message }, 400);
}
}
src/controllers/userController.ts
Controllerはリクエストを処理し、Modelを呼び出し、レスポンスを返す。つまり、ControllerはViewにデータを渡すことができます。
import { Context } from 'hono';
import { UserModel } from '../models/userModel';
import { UserView } from '../views/userView';
export class UserController {
// ControllerはModelとViewに依存する
private userModel: UserModel;
private userView: UserView;
constructor() {
this.userModel = new UserModel();
this.userView = new UserView();
}
/**
* ユーザー取得のリクエストを処理する
* @param c HonoのContext
*/
public async getUser(c: Context): Promise<Response> {
// 1. リクエストからパラメータを取得
const id = c.req.param('id');
// 2. Modelにデータ取得を依頼
const user = await this.userModel.findById(id);
// 3. Modelの結果に応じて、Viewにレスポンス生成を依頼
if (!user) {
return this.userView.renderNotFound(c);
}
return this.userView.renderUser(c, user);
}
/**
* ユーザー作成のリクエストを処理する
* @param c HonoのContext
*/
public async createUser(c: Context): Promise<Response> {
// 1. リクエストからボディを取得
const { name, email } = await c.req.json();
// 2. 入力値の簡易チェック
if (!name || !email) {
return this.userView.renderBadRequest(c, 'Name and email are required');
}
// 3. Modelにビジネスロジックの実行を依頼
const newUser = await this.userModel.createUser({ name, email });
// 4. Modelの結果をViewに渡し、レスポンス生成を依頼 (ステータスコード201を指定)
return this.userView.renderUser(c, newUser, 201);
}
}
src/routes/userRoutes.ts
Honoのルーターで、パスとControllerのメソッドを対応付けます
import { Hono } from 'hono';
import { UserController } from '../controllers/userController';
const userController = new UserController();
export const userRouter = new Hono();
userRouter.get('/:id', (c) => userController.getUser(c));
userRouter.post('/', (c) => userController.createUser(c));
src/index.ts
アプリケーションのエントリーポイント。
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { userRouter } from './routes/userRoutes';
const app = new Hono();
app.route('/users', userRouter);
serve(app, (info) => {
console.log(`MVC Server listening on http://localhost:${info.port}`);
});
4. レイヤードアーキテクチャ
責務の分離を推し進め、関心事を層(レイヤー)に分割するアーキテクチャである。
4.1. 説明と構造
-
プレゼンテーション層 (Presentation Layer):
HTTPリクエスト/レスポンスの責務。 -
アプリケーション層 (Application Layer):
ビジネスロジック、ユースケースを実現する層。 -
ドメイン層 (Domain Layer):
ビジネスの核となるオブジェクト(エンティティ)を定義する。 -
インフラストラクチャ層 (Infrastructure Layer):
データベース(Prisma)など、技術的詳細を実装する。
利点:
- 関心事の明確な分離。
- 各層の独立した開発・テスト。
欠点:
- リクエストが多くの層を通過することによるオーバーヘッド。
- 厳格すぎると不要な複雑さを生むことがある。
4.2. ディレクトリ構造と作成コマンド
.
├── prisma
│ └── schema.prisma
└── src
├── application
│ └── userService.ts
├── domain
│ └── user.ts
├── infrastructure
│ ├── prisma
│ │ └── prismaClient.ts
│ └── userRepository.ts
├── presentation
│ ├── userHandler.ts
│ └── router.ts
└── index.ts
※作成時MVCで作成したファイルが存在するので削除する形の対応をしてください
# ディレクトリを作成
mkdir -p src/application src/domain src/infrastructure/prisma src/presentation
# ファイルを作成
touch src/application/userService.ts src/domain/user.ts src/infrastructure/prisma/prismaClient.ts src/infrastructure/userRepository.ts src/presentation/userHandler.ts src/presentation/router.ts src/index.ts
4.3. コード例
src/domain/user.ts
ドメイン層。ビジネスエンティティの定義に集中する。
export interface User {
id: string;
name: string;
email: string;
}
src/infrastructure/prisma/prismaClient.ts
シングルトンのPrisma Clientを提供する。
import { PrismaClient } from '@prisma/client';
// アプリケーション全体で共有するPrisma Clientの単一インスタンス
export const prisma = new PrismaClient();
src/infrastructure/userRepository.ts
インフラ層。Prismaを用いたデータ永続化を実装する。
import { prisma } from './prisma/prismaClient';
import { User } from '../domain/user';
import { User as PrismaUser } from '@prisma/client';
export class UserRepository {
async findById(id: string): Promise<User | null> {
const user = await prisma.user.findUnique({ where: { id } });
// Prismaのモデルからドメインモデルへのマッピング
return user ? this.toDomainModel(user) : null;
}
async save(data: Omit<User, 'id'>): Promise<User> {
const newUser = await prisma.user.create({ data });
return this.toDomainModel(newUser);
}
// Prismaの型からドメインの型へ変換する責務を持つ
private toDomainModel(prismaUser: PrismaUser): User {
return {
id: prismaUser.id,
name: prismaUser.name,
email: prismaUser.email
};
}
}
src/application/userService.ts
アプリケーション層。ビジネスロジックを担う。Repositoryに依存する。
import { User } from '../domain/user';
import { UserRepository } from '../infrastructure/userRepository';
export class UserService {
constructor(private userRepository: UserRepository) {}
async findUserById(id: string): Promise<User | null> {
return this.userRepository.findById(id);
}
async createNewUser(name: string, email: string): Promise<User> {
return this.userRepository.save({ name, email });
}
}
src/presentation/userHandler.ts
プレゼンテーション層。HTTPの責務に特化し、ビジネスロジックはServiceに委譲する。
import { Context } from 'hono';
import { UserService } from '../application/userService';
export class UserHandler {
constructor(private userService: UserService) {}
async getUser(c: Context): Promise<Response> {
const id = c.req.param('id');
const user = await this.userService.findUserById(id);
if (!user) return c.json({ message: 'User not found' }, 404);
return c.json(user);
}
async createUser(c: Context): Promise<Response> {
const { name, email } = await c.req.json();
const newUser = await this.userService.createNewUser(name, email);
return c.json(newUser, 201);
}
}
src/index.ts (レイヤード)
エントリーポイント。各層のオブジェクトを生成し、依存関係を組み立てる(簡易的なDI)。
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { UserHandler } from './presentation/userHandler';
import { UserService } from './application/userService';
import { UserRepository } from './infrastructure/userRepository';
import { router } from './presentation/router';
// 依存関係の組み立て
const userRepository = new UserRepository();
const userService = new UserService(userRepository);
const userHandler = new UserHandler(userService);
const app = new Hono();
// ルーターにインスタンスを渡す
app.route('/users', router(userHandler));
serve(app, (info) => {
console.log(`Layered Server listening on http://localhost:${info.port}`);
});
5. クリーンアーキテクチャ
テスト容易性、保守性、独立性を極限まで高めることを目指したアーキテクチャである。
5.1. SOLID原則とDI
クリーンアーキテクチャはSOLID原則、特に依存性反転の原則 (DIP) と密接に関連している。
-
S (単一責任の原則: Single Responsibility Principle)
クラスは変更の理由が1つだけであるべき。つまり、クラスは1つの責任または関心事だけを持つべきであり、複数の責任を持つとそれぞれの変更に対して他の責任に影響を与える可能性がある。例えば、ユーザー情報の保存とメール送信を両方行うクラスは、責任が複数あるため良くない。なぜなら、メール送信の仕様が変わったときに、ユーザー情報の保存ロジックに影響を与える可能性がある。この原則に従うと、コードの変更がより安全になり、テストも容易になる。
// 悪い例:複数の責任を持つクラス
class UserManager {
constructor(private database: Database, private emailService: EmailService) {}
saveUser(user: User): void {
// ユーザー情報をデータベースに保存
this.database.save(user);
// ウェルカムメールを送信
const message = `こんにちは、${user.name}さん!`;
this.emailService.send(user.email, "ご登録ありがとうございます", message);
// ログを出力
console.log(`ユーザー ${user.name} が登録されました`);
}
}
// 良い例:責任が分離されたクラス
class UserRepository {
constructor(private database: Database) {}
saveUser(user: User): void {
this.database.save(user);
}
}
class UserNotifier {
constructor(private emailService: EmailService) {}
sendWelcomeEmail(user: User): void {
const message = `こんにちは、${user.name}さん!`;
this.emailService.send(user.email, "ご登録ありがとうございます", message);
}
}
class Logger {
logUserRegistration(user: User): void {
console.log(`ユーザー ${user.name} が登録されました`);
}
}
// 各クラスを連携させるサービス
class UserService {
constructor(
private userRepository: UserRepository,
private userNotifier: UserNotifier,
private logger: Logger
) {}
registerUser(user: User): void {
this.userRepository.saveUser(user);
this.userNotifier.sendWelcomeEmail(user);
this.logger.logUserRegistration(user);
}
}
-
O (オープン・クローズドの原則: Open-Closed Principle)
ソフトウェアエンティティ(クラス、モジュール、関数など)は拡張に対して開かれており、修正に対して閉じているべき。つまり、新しい機能を追加するためにコードを変更するのではなく、既存のコードを変更せずに拡張できるようにするべき。例えば、新しい支払い方法を追加するとき、既存の支払い処理コードを変更するのではなく、新しい支払いクラスを追加するだけで済むようにする。この原則に従うと、既存のコードに対する影響なく、アプリケーションに新しい機能を追加できるようになる。
// 悪い例:新しい形を追加するたびにクラスを修正する必要がある
class AreaCalculator {
calculateArea(shape: any): number {
if (shape.type === 'circle') {
return Math.PI * shape.radius * shape.radius;
} else if (shape.type === 'rectangle') {
return shape.width * shape.height;
}
// 新しい形を追加するたびにこのメソッドを変更する必要がある
return 0;
}
}
// 良い例:インターフェースとポリモーフィズムを使用
interface Shape {
calculateArea(): number;
}
class Circle implements Shape {
constructor(private radius: number) {}
calculateArea(): number {
return Math.PI * this.radius * this.radius;
}
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
calculateArea(): number {
return this.width * this.height;
}
}
// 新しい形を追加しても、AreaCalculatorを変更する必要はない
class Triangle implements Shape {
constructor(
private base: number,
private height: number
) {}
calculateArea(): number {
return (this.base * this.height) / 2;
}
}
class AreaCalculator {
calculateArea(shape: Shape): number {
return shape.calculateArea();
}
}
- L (リスコフの置換原則: Liskov Substitution Principle)
サブタイプ(派生クラス)は、そのスーパータイプ(基底クラス)と置換可能でなければならない。つまり、派生クラスはそのベースクラスの代わりに使用できるべきであり、派生クラスの挙動がベースクラスの挙動と矛盾してはならない。例えば、Bird
クラスを継承したPenguin
クラスがfly()
メソッドをオーバーライドして例外をスローするのは、この原則に違反する。なぜならペンギンは飛べないので、Bird
の代わりにPenguin
を使うとプログラムが正しく動作しなくなるからである。この原則に従うことで、継承関係がより明確になり、コードの堅牢性が高まる。
// 悪い例:派生クラスがベースクラスの前提条件を壊している
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square extends Rectangle {
constructor(size: number) {
super(size, size);
}
// これはLSPに違反している:正方形は幅を変えると高さも変わる
setWidth(width: number): void {
this.width = width;
this.height = width; // 高さも変更してしまう
}
setHeight(height: number): void {
this.height = height;
this.width = height; // 幅も変更してしまう
}
}
// 問題の例:
function calculateAreaWithWidthIncrease(rectangle: Rectangle): number {
// 長方形の幅を5増やす
const originalWidth = rectangle.getWidth();
rectangle.setWidth(originalWidth + 5);
// 高さは変わらないと仮定している
return rectangle.getArea();
}
// 長方形の場合:期待通りに動作する
const rect = new Rectangle(5, 10);
calculateAreaWithWidthIncrease(rect); // 期待: (5+5) * 10 = 100
// 正方形の場合:予期せぬ結果になる
const square = new Square(5);
calculateAreaWithWidthIncrease(square); // 期待外: (5+5) * (5+5) = 100(高さも変わっている)
// 良い例:継承を使わず、明示的にクラスを分ける
interface Shape {
getArea(): number;
}
class Rectangle implements Shape {
constructor(private width: number, private height: number) {}
setWidth(width: number): void {
this.width = width;
}
setHeight(height: number): void {
this.height = height;
}
getArea(): number {
return this.width * this.height;
}
}
class Square implements Shape {
constructor(private size: number) {}
setSize(size: number): void {
this.size = size;
}
getArea(): number {
return this.size * this.size;
}
}
-
I (インターフェース分離の原則: Interface Segregation Principle)
クライアントは、使用しないメソッドに依存すべきではない。つまり、大きくて太ったインターフェースを複数の小さなインターフェースに分割すべきという考え方。例えば、プリンタとスキャナーの機能をすべて含む大きなインターフェースがあると、プリンタだけを実装するクラスはスキャン関連のメソッドも実装しなければならなくなる。この原則に従って、機能ごとに小さなインターフェースに分割することで、クラスは必要な機能だけを実装でき、コードの柔軟性が高まる。
// 悪い例:太ったインターフェース
interface WorkerMachine {
print(document: Document): void;
scan(document: Document): Image;
fax(document: Document): void;
staple(documents: Document[]): void;
}
// 問題点:すべてのプリンタがスキャンやファックスをサポートしているわけではない
class SimplePrinter implements WorkerMachine {
print(document: Document): void {
// 印刷機能の実装
}
scan(document: Document): Image {
// この機能はサポートしていないが、実装しなければならない
throw new Error("スキャン機能はサポートしていません");
}
fax(document: Document): void {
// この機能はサポートしていないが、実装しなければならない
throw new Error("ファックス機能はサポートしていません");
}
staple(documents: Document[]): void {
// この機能はサポートしていないが、実装しなければならない
throw new Error("ステープル機能はサポートしていません");
}
}
// 良い例:役割ごとに分割されたインターフェース
interface Printer {
print(document: Document): void;
}
interface Scanner {
scan(document: Document): Image;
}
interface FaxMachine {
fax(document: Document): void;
}
interface Stapler {
staple(documents: Document[]): void;
}
// 各クラスは必要なインターフェースだけを実装する
class SimplePrinter implements Printer {
print(document: Document): void {
// 印刷機能の実装
}
}
// 多機能な機器は複数のインターフェースを実装できる
class MultiFunctionPrinter implements Printer, Scanner, FaxMachine {
print(document: Document): void {
// 印刷機能の実装
}
scan(document: Document): Image {
// スキャン機能の実装
return new Image();
}
fax(document: Document): void {
// ファックス機能の実装
}
}
class ProfessionalPrinter implements Printer, Stapler {
print(document: Document): void {
// 印刷機能の実装
}
staple(documents: Document[]): void {
// ステープル機能の実装
}
}
-
D (依存性反転の原則: Dependency Inversion Principle)
上位モジュールは下位モジュールに依存すべきでなく、両者は抽象(インターフェースやアブストラクトクラス)に依存すべき。抽象は詳細に依存すべきでなく、詳細が抽象に依存すべき。例えば、高レベルのビジネスロジックが直接MySQLデータベースに依存するのではなく、データアクセスインターフェースに依存し、そのインターフェースの実装としてMySQLリポジトリを使用する。この原則に従うと、データベースをMySQLからMongoDBに変更したい場合、高レベルのビジネスロジックを変更せずに、新しいMongoDBリポジトリを実装するだけで済む。
// 悪い例:上位モジュールが下位モジュールに直接依存している
class MySQLUserRepository {
getUser(id: string): User {
// MySQLからユーザーを取得する実装
return new User(id, "ユーザー名");
}
}
class UserService {
constructor(private userRepository: MySQLUserRepository) {}
getUserById(id: string): User {
return this.userRepository.getUser(id);
}
}
// 問題点:UserServiceはMySQLUserRepositoryに直接依存しているため、
// 異なるデータベースに切り替えたい場合、UserServiceも変更する必要がある
// 良い例:上位モジュールと下位モジュールが共に抽象に依存している
interface UserRepository {
getUser(id: string): User;
}
class MySQLUserRepository implements UserRepository {
getUser(id: string): User {
// MySQLからユーザーを取得する実装
return new User(id, "ユーザー名");
}
}
class MongoDBUserRepository implements UserRepository {
getUser(id: string): User {
// MongoDBからユーザーを取得する実装
return new User(id, "ユーザー名");
}
}
class UserService {
constructor(private userRepository: UserRepository) {}
getUserById(id: string): User {
return this.userRepository.getUser(id);
}
}
// 依存性注入によって、どのリポジトリ実装を使用するか決定できる
const mySQLRepo = new MySQLUserRepository();
const mongoRepo = new MongoDBUserRepository();
// MySQLを使用する場合
const userServiceWithMySQL = new UserService(mySQLRepo);
// MongoDBに切り替える場合(UserServiceのコードは変更不要)
const userServiceWithMongo = new UserService(mongoRepo);
// テストで使用するモックリポジトリ
class MockUserRepository implements UserRepository {
getUser(id: string): User {
return new User(id, "モックユーザー");
}
}
// テストでモックリポジトリを注入
const userServiceForTest = new UserService(new MockUserRepository());
サブタイプとそのスーパータイプ
サブタイプとスーパータイプは、オブジェクト指向プログラミングにおける継承関係を表します。
- スーパータイプ(親クラス・基底クラス): より一般的で抽象的な概念を表すクラスやインターフェース
- サブタイプ(子クラス・派生クラス): スーパータイプを継承し、より具体的な実装を持つクラス
例:
// スーパータイプ(抽象的)
interface Animal {
makeSound(): string;
}
// サブタイプ(具体的)
class Dog implements Animal {
makeSound(): string {
return "ワンワン";
}
}
class Cat implements Animal {
makeSound(): string {
return "ニャーニャー";
}
}
上位(抽象)モジュールと下位(具体)モジュールとは?
これは依存性反転の原則(DIP)における重要な概念です。
上位(抽象)モジュールとは?
- ビジネスロジックや重要な処理を担当するモジュール
- 「何をするか」を定義するが、「どうやってするか」の詳細は知らない
- より安定しており、変更されにくい
- 抽象(インターフェース)に依存する
例:本アプリケーションでは
-
UserUsecase
(ユーザー作成・取得のビジネスロジック) -
UserController
(HTTPリクエストの処理ロジック)
下位(具体)モジュールとは?
- 技術的な詳細や実装方法を担当するモジュール
- 「どうやってするか」を具体的に実装する
- 変更されやすい(データベースの変更、外部APIの変更など)
- 具体的な技術に依存する
例:本アプリケーションでは
-
MysqlUserRepository
(MySQLでのデータ保存方法) -
RedisUserRepository
(Redisでのデータ保存方法) -
PrismaClient
(Prismaを使ったDB接続方法)
従来の問題のある依存関係:
上位モジュール → 下位モジュール
(ビジネスロジック) → (MySQL実装)
この場合、MySQLからPostgreSQLに変更したい時、ビジネスロジックも変更が必要になってしまいます。
依存性反転後の理想的な依存関係:
上位モジュール → インターフェース ← 下位モジュール
(ビジネスロジック) → (Repository抽象) ← (MySQL実装)
この場合、MySQL実装をPostgreSQL実装に差し替えても、ビジネスロジックは一切変更不要です。
-
DI (依存性注入: Dependency Injection):
依存性反転を実現するための具体的なテクニックである。クラスが必要とするオブジェクト(依存オブジェクト)を、クラスの外部から与える(注入する)設計パターンである。これにより、クラスは具体的な実装ではなく、抽象(インターフェース)にのみ依存できる。
今回の実装では、index.ts
がDIコンテナの役割を果たし、Usecase
にRepository
の具体的な実装(例: MysqlUserRepository
)を注入する。
5.2. 説明と構造
中心的なルールは依存性のルール。依存関係は必ず外側から内側へ向かう。これを実現するために 依存性逆転の原則(DIP) を多用する。
クリーンアーキテクチャの恩恵:
-
フレームワーク非依存
UI、データベース、Webフレームワークなどを容易に変更できる。 -
テスト容易性
ビジネスロジックをUIやデータベースから分離してテストできる。 -
UI非依存
同じビジネスロジックをWeb API、CLI、デスクトップアプリなど、異なるUIで再利用できる。 -
データベース非依存
データベースの種類やORMをビジネスロジックに影響を与えることなく変更できる。
5.3. ディレクトリ構造と作成コマンド
.
├── prisma
│ └── schema.prisma
└── src
├── domain
│ ├── entity
│ │ └── user.ts
│ └── repository
│ └── userRepository.ts
├── infrastructure
│ ├── mysql
│ │ ├── mysqlUserRepository.ts
│ │ └── prismaClient.ts
│ └── redis
│ ├── redisClient.ts
│ └── redisUserRepository.ts
├── interface
│ ├── dto
│ │ └── userDto.ts
│ └── handler
│ └── userHandler.ts
├── usecase
│ └── userUsecase.ts
├── cli.ts
├── index.ts
└── router.ts
※MVCやレイヤードのファイルが残っているので消すか別のディレクトリを作成してから実行しましょう!
mkdir -p src/domain/entity src/infrastructure/mysql src/infrastructure/redis src/interface/dto src/interface/handler src/domain/repository src/usecase
touch src/domain/entity/user.ts src/infrastructure/mysql/prismaClient.ts src/infrastructure/mysql/mysqlUserRepository.ts src/infrastructure/redis/redisClient.ts src/infrastructure/redis/redisUserRepository.ts src/interface/dto/userDto.ts src/interface/handler/userHandler.ts src/domain/repository/userRepository.ts src/usecase/userUsecase.ts src/cli.ts src/index.ts src/router.ts
5.4. コード例
5.4.1 Domain Layer
ドメイン層は、アプリケーションの中心に位置し、ビジネスロジックと業務ルールをカプセル化した、技術的詳細に依存しない核心部分である
src/domain/entity/user.ts
(Entity)
ビジネスの核となるデータと振る舞いを持つオブジェクト。識別子を持ち、ビジネスルールをカプセル化する。データベースやフレームワークから独立して存在する純粋なドメインモデル。
/**
* @file Userエンティティの定義
* @description ビジネスの核となるUserオブジェクトのインターフェース。
* フレームワークや特定のORMの型に依存しない、純粋なドメインオブジェクトを表す。
* タイムスタンプフィールド (createdAt, updatedAt) は含まない。
*/
/**
* Userエンティティのプロパティを定義するインターフェース。
*/
export interface User {
id: string;
name: string;
email: string;
}
src/domain/repository/userRepository.ts
(Interface / Port)
エンティティの保存と取得を抽象化するインターフェース。データストレージの詳細を隠蔽し、コレクションのようにエンティティを操作できるようにする。実装はインフラ層で行われるが、インターフェースはドメイン層に属する。
/**
* @file UserRepositoryインターフェースの定義
* @description Userエンティティのデータ永続化操作に関する「契約」を定義する。
* このインターフェースはドメイン層に属し、具体的な実装(MySQL, Redisなど)はインフラストラクチャ層で行われる。
*/
import type { User } from '@/domain/entity/user';
/**
* ユーザーリポジトリのインターフェース。
* Userエンティティに対してどのようなデータ操作を行うかを定義する。
*/
export interface IUserRepository {
/**
* 指定されたIDを持つユーザーを検索する。
* @param id - 検索するユーザーのID。
* @returns 見つかった場合はUserオブジェクト、見つからなければnull。
*/
findById(id: string): Promise<User | null>;
/**
* 指定されたメールアドレスを持つユーザーを検索する。
* @param email - 検索するユーザーのメールアドレス。
* @returns 見つかった場合はUserオブジェクト、見つからなければnull。
*/
findByEmail(email: string): Promise<User | null>;
/**
* 新しいユーザーを作成して永続化層に保存する。
* @param data - 作成するユーザーのデータ(IDを除く)。IDはリポジトリ実装内で生成される想定。
* @returns 作成されたUserエンティティ(IDを含む)。
*/
create(data: Omit<User, 'id'>): Promise<User>;
/**
* 既存のユーザー情報を永続化層で更新する。
* @param user - 更新するUserエンティティ。IDで対象を特定し、他のプロパティを更新する。
* @returns 更新されたUserエンティティ。対象が見つからない場合はエラーを投げるかnullを返す(実装による)。
*/
update(user: User): Promise<User | null>;
// save(user: User): Promise<User>; // createとupdateを兼ねる場合、このようなシグネチャも考えられる
/**
* 指定されたIDを持つユーザーを削除する。
* @param id - 削除するユーザーのID。
* @returns 削除が成功した場合はtrue、ユーザーが見つからない等の理由で失敗した場合はfalse。
*/
deleteById(id: string): Promise<boolean>;
/**
* すべてのユーザーを取得する。
* @returns Userエンティティの配列。
*/
findAll(): Promise<User[]>;
}
5.4.2. Usecase Layer
ユースケース層とは、アプリケーションの具体的な機能やビジネスロジックを実装し、ドメイン層のエンティティを操作して特定のシナリオを実現する中心的な層
src/usecase/userUsecase.ts
/**
* @file UserUsecaseクラスの定義
* @description ユーザーに関連するアプリケーションのユースケース(ビジネスロジック)を実装する。
* ドメインエンティティとリポジトリインターフェースに依存し、具体的なインフラストラクチャには依存しない。
*/
import type { User } from '@/domain/entity/user';
import type { IUserRepository } from '@/domain/repository/userRepository';
import type { CreateUserDto, UpdateUserDto, UserDto } from '@/interface/dto/userDto';
/**
* ユーザーユースケースのインターフェース。
*/
export interface IUserUsecase {
/**
* 指定されたIDのユーザーを取得する。
* @param id - 取得するユーザーのID。
* @returns UserDtoオブジェクト、またはユーザーが見つからない場合はnull。
*/
getUserById(id: string): Promise<UserDto | null>;
/**
* 新しいユーザーを作成する。
* @param dto - ユーザー作成のためのデータ転送オブジェクト。
* @returns 作成されたユーザーのUserDtoオブジェクト。
* @throws Error - メールアドレスが既に存在する場合など、ビジネスルール違反時。
*/
createUser(dto: CreateUserDto): Promise<UserDto>;
/**
* すべてのユーザーを取得する。
* @returns UserDtoオブジェクトの配列。
*/
getAllUsers(): Promise<UserDto[]>;
/**
* ユーザー情報を更新する。
* @param id - 更新するユーザーのID。
* @param dto - 更新内容を含むデータ転送オブジェクト。
* @returns 更新されたユーザーのUserDtoオブジェクト、またはユーザーが見つからない場合はnull。
* @throws Error - メールアドレス重複など、ビジネスルール違反時。
*/
updateUser(id: string, dto: UpdateUserDto): Promise<UserDto | null>;
/**
* ユーザーを削除する。
* @param id - 削除するユーザーのID。
* @returns 削除が成功した場合はtrue、ユーザーが見つからない場合はfalse。
*/
deleteUser(id: string): Promise<boolean>;
}
/**
* UserUsecaseクラス。IUserUsecaseインターフェースを実装する。
*/
export class UserUsecase implements IUserUsecase {
/**
* UserUsecaseのコンストラクタ。
* @param userRepository - IUserRepositoryインターフェースを実装したリポジトリのインスタンス。
* 依存性注入により外部から提供される。
*/
constructor(private readonly userRepository: IUserRepository) {}
/**
* UserエンティティをUserDtoに変換するプライベートメソッド。
* @param user - 変換元のUserエンティティ。
* @returns 変換されたUserDtoオブジェクト。タイムスタンプ情報は含まない。
*/
private toDto(user: User): UserDto {
return {
id: user.id,
name: user.name,
email: user.email,
};
}
/** @inheritdoc */
async getUserById(id: string): Promise<UserDto | null> {
const user = await this.userRepository.findById(id);
return user ? this.toDto(user) : null;
}
/** @inheritdoc */
async createUser(dto: CreateUserDto): Promise<UserDto> {
const existingUser = await this.userRepository.findByEmail(dto.email);
if (existingUser) {
throw new Error(`Business Rule Violation: User with email ${dto.email} already exists.`);
}
// リポジトリのcreateメソッドを使用して永続化とID生成を行う
const newUserEntity = await this.userRepository.create({
name: dto.name,
email: dto.email,
});
return this.toDto(newUserEntity);
}
/** @inheritdoc */
async getAllUsers(): Promise<UserDto[]> {
const users = await this.userRepository.findAll();
return users.map(user => this.toDto(user));
}
/** @inheritdoc */
async updateUser(id: string, dto: UpdateUserDto): Promise<UserDto | null> {
const existingUser = await this.userRepository.findById(id);
if (!existingUser) {
return null; // Or throw a specific "NotFound" error
}
// メールアドレスが変更され、かつ新しいメールアドレスが他で使用されているかチェック
if (dto.email && dto.email !== existingUser.email) {
const userWithNewEmail = await this.userRepository.findByEmail(dto.email);
if (userWithNewEmail && userWithNewEmail.id !== id) {
throw new Error(`Business Rule Violation: Email ${dto.email} is already in use by another user.`);
}
}
const userToUpdate: User = {
id: existingUser.id, // IDは変更しない
name: dto.name ?? existingUser.name, // 提供されていなければ既存の値を使用
email: dto.email ?? existingUser.email, // 提供されていなければ既存の値を使用
};
const updatedUser = await this.userRepository.update(userToUpdate);
return updatedUser ? this.toDto(updatedUser) : null;
}
/** @inheritdoc */
async deleteUser(id: string): Promise<boolean> {
// 事前にユーザーが存在するか確認することも可能だが、
// リポジトリのdeleteByIdが成否を返す契約ならそれに従う。
return this.userRepository.deleteById(id);
}
}
5.4.3. Infrastructure Layer
インフラストラクチャ層は、データベースアクセスや外部APIとの連携など、技術的な実装の詳細を担当し、ドメイン層で定義されたリポジトリインターフェースの具体的な実装を提供する
src/infrastructure/mysql/prismaClient.ts
PrismaClientのシングルトンインスタンスを提供するモジュール。
prismaに接続するためのファイル。
/**
* @file PrismaClientのシングルトンインスタンスを提供するモジュール。
* @description アプリケーション全体で単一のPrismaClientインスタンスを共有するために使用される。
*/
import { PrismaClient } from '@prisma/client';
/**
* PrismaClientのシングルトンインスタンス。
*/
export const prisma = new PrismaClient({
// log: ['query', 'info', 'warn', 'error'], // 開発時に有効化
});
/**
* PrismaClientの接続を安全に切断する関数。
*/
export async function disconnectPrisma(): Promise<void> {
await prisma.$disconnect();
}
src/infrastructure/mysql/mysqlUserRepository.ts
prismaを用いて実際にデータ通信部分を実装するファイル。
/**
* @file MysqlUserRepositoryクラスの定義
* @description IUserRepositoryインターフェースをMySQL (Prisma ORM) を使用して実装する。
* Userエンティティからタイムスタンプフィールドが除外されたバージョン。
*/
import type { IUserRepository } from '@/domain/repository/userRepository';
import type { User } from '@/domain/entity/user';
import { prisma } from './prismaClient';
import type { User as PrismaUser } from '@prisma/client'; // Prismaが生成する型
import { v4 as uuidv4 } from 'uuid'; // ID生成用
/**
* MysqlUserRepositoryクラス。IUserRepositoryインターフェースを実装する。
*/
export class MysqlUserRepository implements IUserRepository {
/**
* Prisma UserモデルをドメインのUserモデルに変換する。
* タイムスタンプフィールドはマッピングしない。
* @param prismaUser - PrismaのUserモデルオブジェクト。
* @returns ドメインのUserモデルオブジェクト。
*/
private toDomainModel(prismaUser: PrismaUser): User {
return {
id: prismaUser.id,
name: prismaUser.name,
email: prismaUser.email,
};
}
/** @inheritdoc */
async findById(id: string): Promise<User | null> {
const prismaUser = await prisma.user.findUnique({
where: { id },
});
return prismaUser ? this.toDomainModel(prismaUser) : null;
}
/** @inheritdoc */
async findByEmail(email: string): Promise<User | null> {
const prismaUser = await prisma.user.findUnique({
where: { email },
});
return prismaUser ? this.toDomainModel(prismaUser) : null;
}
/** @inheritdoc */
async create(data: Omit<User, 'id'>): Promise<User> {
const newPrismaUser = await prisma.user.create({
data: {
id: uuidv4(), // Prismaスキーマで @default(uuid()) がある場合、この行は不要になることがある
name: data.name,
email: data.email,
// createdAt, updatedAt はスキーマから削除されたため、ここでは指定しない
},
});
return this.toDomainModel(newPrismaUser);
}
/** @inheritdoc */
async update(user: User): Promise<User | null> {
try {
const updatedPrismaUser = await prisma.user.update({
where: { id: user.id },
data: {
name: user.name,
email: user.email,
// updatedAt はスキーマから削除されたため、Prismaは自動更新しない
},
});
return this.toDomainModel(updatedPrismaUser);
} catch (error: unknown) {
if (typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'P2025') {
console.warn(`User with ID ${user.id} not found for update.`);
return null;
}
console.error('Error updating user in MysqlUserRepository:', error);
throw error; // その他の予期せぬエラーは再スロー
}
}
/** @inheritdoc */
async deleteById(id: string): Promise<boolean> {
try {
await prisma.user.delete({
where: { id },
});
return true;
} catch (error: any) {
if (error.code === 'P2025') { // Record to delete does not exist.
return false; // 対象が存在しない場合は false を返す
}
console.error('Error deleting user by ID in MysqlUserRepository:', error);
throw error; // その他のエラーは再スロー
}
}
/** @inheritdoc */
async findAll(): Promise<User[]> {
const prismaUsers = await prisma.user.findMany();
return prismaUsers.map(user => this.toDomainModel(user));
}
}
src/infrastructure/redis/redisClient.ts
Redisクライアントのインスタンスを提供するモジュール。
redisに接続するファイル。
/**
* @file Redisクライアントのインスタンスを提供するモジュール。
*/
import { createClient, type RedisClientType } from 'redis';
let redisClientInstance: RedisClientType | null = null;
/**
* Redisクライアントのシングルトンインスタンスを取得または作成する。
* @returns RedisClientTypeのインスタンス。
* @throws Error Redisへの接続に失敗した場合。
*/
export async function getRedisClient(): Promise<RedisClientType> {
if (redisClientInstance && redisClientInstance.isOpen) {
return redisClientInstance;
}
const redisHost = process.env.REDIS_HOST || '127.0.0.1';
const redisPort = process.env.REDIS_PORT || '6379';
const redisURL = `redis://<span class="math-inline">\{redisHost\}\:</span>{redisPort}`;
const client = createClient({ url: redisURL });
client.on('error', (err) => console.error('Redis Client Error', err));
// 他のイベントリスナーも必要に応じて追加
try {
if (!client.isOpen) {
await client.connect();
}
redisClientInstance = client as unknown as RedisClientType;
return redisClientInstance;
} catch (err) {
console.error('Failed to connect to Redis:', err);
throw err;
}
}
/**
* Redisクライアントの接続を安全に切断する関数。
*/
export async function disconnectRedis(): Promise<void> {
if (redisClientInstance && redisClientInstance.isOpen) {
await redisClientInstance.quit();
redisClientInstance = null;
}
}
src/infrastructure/redis/redisUserRepository.ts
redisとデータ通信するための具体実装をするファイル
/**
* @file RedisUserRepositoryクラスの定義
* @description IUserRepositoryインターフェースをRedisを使用して実装する。
* タイムスタンプフィールドが除外されたUserエンティティに対応。
*/
import type { IUserRepository } from '@/domain/repository/userRepository';
import type { User } from '@/domain/entity/user';
import { getRedisClient } from './redisClient';
import type { RedisClientType } from 'redis';
import { v4 as uuidv4 } from 'uuid';
const USER_KEY_PREFIX = 'user:';
/**
* RedisUserRepositoryクラス。IUserRepositoryインターフェースを実装する。
*/
export class RedisUserRepository implements IUserRepository {
private redisClient!: RedisClientType;
/**
* Redisクライアントを確実に取得するためのヘルパーメソッド。
*/
private async getClient(): Promise<RedisClientType> {
if (!this.redisClient || !this.redisClient.isOpen) {
this.redisClient = await getRedisClient();
}
return this.redisClient;
}
/** @inheritdoc */
async findById(id: string): Promise<User | null> {
const client = await this.getClient();
const data = await client.get(`<span class="math-inline">\{USER\_KEY\_PREFIX\}</span>{id}`);
return data ? (JSON.parse(data) as User) : null;
}
/** @inheritdoc */
async findByEmail(email: string): Promise<User | null> {
const client = await this.getClient();
// 注意: このemail検索はRedisの基本機能だけでは非効率。
// 効率化にはセカンダリインデックスの仕組みが必要 (例: email -> id のマッピングを別途保存)。
// ここではデモのため、全キーを走査するような非効率な方法を示唆するが、実際には避けるべき。
console.warn(
'RedisUserRepository.findByEmail is NOT EFFICIENT for production without secondary indexing.'
);
const keys = await client.keys(`${USER_KEY_PREFIX}*`);
for (const key of keys) {
const data = await client.get(key);
if (data) {
const user = JSON.parse(data) as User;
if (user.email === email) {
return user;
}
}
}
return null;
}
/** @inheritdoc */
async create(data: Omit<User, 'id'>): Promise<User> {
const client = await this.getClient();
const user: User = {
id: uuidv4(),
name: data.name,
email: data.email,
};
await client.set(`<span class="math-inline">\{USER\_KEY\_PREFIX\}</span>{user.id}`, JSON.stringify(user));
return user;
}
/** @inheritdoc */
async update(user: User): Promise<User | null> {
const client = await this.getClient();
// Redisで更新する場合、対象が存在するかどうかを先に確認することが推奨される。
const existingData = await client.get(`<span class="math-inline">\{USER\_KEY\_PREFIX\}</span>{user.id}`);
if (!existingData) {
console.warn(`User with ID ${user.id} not found in Redis for update.`);
return null; // 対象が存在しない場合はnullを返す
}
await client.set(`<span class="math-inline">\{USER\_KEY\_PREFIX\}</span>{user.id}`, JSON.stringify(user));
return user;
}
/** @inheritdoc */
async deleteById(id: string): Promise<boolean> {
const client = await this.getClient();
const result = await client.del(`<span class="math-inline">\{USER\_KEY\_PREFIX\}</span>{id}`);
return result > 0; // 1つ以上のキーが削除されれば成功
}
/** @inheritdoc */
async findAll(): Promise<User[]> {
const client = await this.getClient();
const keys = await client.keys(`${USER_KEY_PREFIX}*`);
if (keys.length === 0) {
return [];
}
// MGETを使うと複数のキーを一度に取得できる
const results = await client.mGet(keys);
return results
.filter((data): data is string => data !== null) // null (キーが存在しない) をフィルタリング
.map((data) => JSON.parse(data) as User);
}
}
5.4.4. Interface Adapter Layer
インターフェース層は、外部システムとビジネスロジック(ユースケース層)を橋渡しするアダプターの役割を担う。外部データを内部モデルに変換し、依存関係が内側に向くよう制御する。
src/interface/dto/userDto.ts
APIの入出力やユースケース間でのデータ転送に使用されるオブジェクトの型を定義するファイル。
/**
* @file UserDTO (Data Transfer Object) の定義
* @description APIの入出力やユースケース間でのデータ転送に使用されるオブジェクトの型を定義する。
* タイムスタンプフィールドは含まない。
*/
import { z } from 'zod';
/**
* ユーザー作成時の入力データに対するバリデーションスキーマ。
*/
export const CreateUserDtoSchema = z.object({
name: z.string().min(1, { message: 'Name is required.' }).max(100),
email: z.string().email({ message: 'Invalid email address.' }),
});
export type CreateUserDto = z.infer<typeof CreateUserDtoSchema>;
/**
* ユーザー更新時の入力データに対するバリデーションスキーマ。
*/
export const UpdateUserDtoSchema = z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
});
export type UpdateUserDto = z.infer<typeof UpdateUserDtoSchema>;
/**
* APIレスポンスやユースケースの出力として使用されるユーザー情報のDTO。
* タイムスタンプ情報は含まない。
*/
export interface UserDto {
id: string;
name: string;
email: string;
}
src/interface/handler/userHandler.ts
Honoフレームワークのコンテキストを受け取り、HTTPリクエストの処理、バリデーション、ユースケースの呼び出し、HTTPレスポンスの生成を担当する。
/**
* @file UserHandlerクラスの定義
* @description Honoフレームワークのコンテキストを受け取り、
* HTTPリクエストの処理、バリデーション、ユースケースの呼び出し、
* HTTPレスポンスの生成を担当する。
*/
import type { Context } from 'hono';
import type { IUserUsecase } from '@/usecase/userUsecase';
import { CreateUserDtoSchema, UpdateUserDtoSchema } from '@/interface/dto/userDto';
/**
* UserHandlerクラス。
*/
export class UserHandler {
constructor(private readonly userUsecase: IUserUsecase) {}
/**
* 新しいユーザーを作成するハンドラーメソッド。
* @param c - Honoのコンテキストオブジェクト。
* @returns HTTPレスポンス。
*/
async createUser(c: Context): Promise<Response> {
try {
const body = await c.req.json();
const validationResult = CreateUserDtoSchema.safeParse(body);
if (!validationResult.success) {
return c.json({ message: 'Invalid input', errors: validationResult.error.flatten().fieldErrors }, 400);
}
const createdUser = await this.userUsecase.createUser(validationResult.data);
return c.json(createdUser, 201);
} catch (error: unknown) {
// エラーハンドリングの詳細は前回回答を参照
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
if (message.includes('already exists')) {
return c.json({ message }, 409);
}
console.error('Error in createUser handler:', error);
return c.json({ message: 'Failed to create user', error: message }, 500);
}
}
/**
* 指定されたIDのユーザーを取得するハンドラーメソッド。
* @param c - Honoのコンテキストオブジェクト。
* @returns HTTPレスポンス。
*/
async getUserById(c: Context): Promise<Response> {
try {
const id = c.req.param('id');
const user = await this.userUsecase.getUserById(id);
if (!user) {
return c.json({ message: 'User not found.' }, 404);
}
return c.json(user, 200);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
console.error('Error in getUserById handler:', error);
return c.json({ message: 'Failed to get user', error: message }, 500);
}
}
/**
* すべてのユーザーを取得するハンドラーメソッド。
* @param c - Honoのコンテキストオブジェクト。
* @returns HTTPレスポンス。
*/
async getAllUsers(c: Context): Promise<Response> {
try {
const users = await this.userUsecase.getAllUsers();
return c.json(users, 200);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
console.error('Error in getAllUsers handler:', error);
return c.json({ message: 'Failed to get all users', error: message }, 500);
}
}
/**
* ユーザー情報を更新するハンドラーメソッド。
* @param c - Honoのコンテキストオブジェクト。
* @returns HTTPレスポンス。
*/
async updateUser(c: Context): Promise<Response> {
try {
const id = c.req.param('id');
const body = await c.req.json();
const validationResult = UpdateUserDtoSchema.safeParse(body);
if (!validationResult.success) {
return c.json({ message: 'Invalid input', errors: validationResult.error.flatten().fieldErrors }, 400);
}
if (Object.keys(validationResult.data).length === 0) {
return c.json({ message: 'No update fields provided.' }, 400);
}
const updatedUser = await this.userUsecase.updateUser(id, validationResult.data);
if (!updatedUser) {
return c.json({ message: 'User not found or no update occurred.' }, 404);
}
return c.json(updatedUser, 200);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
if (message.includes('already in use')) {
return c.json({ message }, 409);
}
console.error('Error in updateUser handler:', error);
return c.json({ message: 'Failed to update user', error: message }, 500);
}
}
/**
* ユーザーを削除するハンドラーメソッド。
* @param c - Honoのコンテキストオブジェクト。
* @returns HTTPレスポンス。
*/
async deleteUser(c: Context): Promise<Response> {
try {
const id = c.req.param('id');
const success = await this.userUsecase.deleteUser(id);
if (!success) {
return c.json({ message: 'User not found or failed to delete.' }, 404);
}
return c.json({ message: 'User deleted successfully.' }, 200);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'An unexpected error occurred';
console.error('Error in deleteUser handler:', error);
return c.json({ message: 'Failed to delete user', error: message }, 500);
}
}
}
5.4.5. Application Entry Points & Router
アプリケーションのエントリーポイントとルーティングを設定する。
src/router.ts
ルーティングの設定
/**
* @file APIルーターのセットアップを行うモジュール。
*/
import { Hono } from 'hono';
import type { UserHandler } from '@/interface/handler/userHandler';
/**
* UserHandlerのルートをHonoアプリケーションにセットアップする。
* @param userHandler - UserHandlerのインスタンス。
* @returns ルートが登録されたHonoアプリケーションインスタンス。
*/
export function setupUserRoutes(userHandler: UserHandler): Hono {
const userRouter = new Hono();
userRouter.get('/', (c) => userHandler.getAllUsers(c));
userRouter.post('/', (c) => userHandler.createUser(c));
userRouter.get('/:id', (c) => userHandler.getUserById(c));
userRouter.put('/:id', (c) => userHandler.updateUser(c));
userRouter.delete('/:id', (c) => userHandler.deleteUser(c));
return userRouter;
}
src/index.ts
(Web APIエントリーポイント / Composition Root)
アプリケーションのエントリーポイントを定義
/**
* @file アプリケーションのメインエントリーポイント (Web API)。
*/
import { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { UserUsecase } from '@/usecase/userUsecase';
import { UserHandler } from '@/interface/handler/userHandler';
import { setupUserRoutes } from '@/router';
import { MysqlUserRepository } from '@/infrastructure/mysql/mysqlUserRepository';
import { RedisUserRepository } from '@/infrastructure/redis/redisUserRepository';
import { disconnectPrisma } from '@/infrastructure/mysql/prismaClient';
import { disconnectRedis, getRedisClient } from '@/infrastructure/redis/redisClient';
import type { IUserRepository } from '@/domain/repository/userRepository';
async function bootstrap() {
const USE_REDIS_FOR_USERS = process.env.USE_USER_REPO === 'redis';
let userRepository: IUserRepository;
if (USE_REDIS_FOR_USERS) {
try {
await getRedisClient();
userRepository = new RedisUserRepository();
console.log('Using RedisUserRepository for API.');
} catch (redisError) {
console.error('API: Failed to connect to Redis, falling back to MySQL:', redisError);
userRepository = new MysqlUserRepository();
}
} else {
userRepository = new MysqlUserRepository();
console.log('Using MySQLUserRepository for API.');
}
const userUsecase = new UserUsecase(userRepository);
const userHandler = new UserHandler(userUsecase);
const app = new Hono();
app.use('*', async (c, next) => { /* ... logging middleware ... */ await next(); });
const userRouter = setupUserRoutes(userHandler);
app.route('/api/users', userRouter);
app.get('/', (c) => c.text('Clean Architecture API (User Simple)'));
app.onError((err, c) => { /* ... error handling ... */ return c.json({ message: err.message }, 500);});
const port = parseInt(process.env.PORT || '3000');
serve({ fetch: app.fetch, port }, (info) => console.log(`API Server listening on http://localhost:${info.port}`));
// Graceful shutdown
process.on('SIGINT', async () => { /* ... cleanup ... */ process.exit(0); });
process.on('SIGTERM', async () => { /* ... cleanup ... */ process.exit(0); });
}
bootstrap().catch((error) => {
console.error('Failed to bootstrap API:', error);
process.exit(1);
});
6. アプリケーションの実行方法
6.1. データベースの準備
-
bun.lock
を作成する
bun install
-
docker compose up -d
を実行してコンテナを起動する。
docker compose up -d
- Prisma Migrateを実行して、
schema.prisma
の内容をデータベースに反映させる。
# コンテナ内でPrisma Migrateコマンドを実行
docker compose exec app bunx prisma migrate dev --name init
このコマンドは、prisma/migrations
ディレクトリにマイグレーションファイルを生成し、データベースにテーブルを作成する。
6.2. Web APIの実行
-
docker compose up --build
を実行する。
docker compose up --build -d
-
curl
でAPIを叩く。
# ユーザーを作成
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Prisma User", "email": "prisma.user@example.com"}'
# ユーザーを取得
curl http://localhost:3000/api/users/<返ってきたID>
6.3 データの確認
データベース内にデータが格納されたかを確認する.
本来はrootユーザーで入らず,適切な権限をユーザーを作成しないといけないが今回は割愛する
docker compose exec db mysql -u root -prootpassword testdb -e "SELECT * FROM users;"
実行結果
docker compose exec db mysql -u root -prootpassword testdb -e "SELECT * FROM users;"
+--------------------------------------+-------------------------+-------------+
| id | email | name |
+--------------------------------------+-------------------------+-------------+
| de1ea905-7b9f-4792-9ffd-b6c5b25d6527 | prisma.user@example.com | Prisma User |
+--------------------------------------+-------------------------+-------------+
6.4. CLIツールの実行
cli.ts
はWeb APIと同様にUsecaseを再利用する。
このように
import { UserModel } from './models/userModel';
import { prisma } from './lib/prismaClient';
async function main() {
const command = process.argv[2];
const name = process.argv[3];
const email = process.argv[4];
if (command === 'createUser') {
if (!name || !email) {
console.error('Usage: bun run src/cli.ts createUser <name> <email>');
process.exit(1);
}
try {
const userModel = new UserModel();
const newUser = await userModel.createUser({ name, email });
console.log('✅ User created successfully:');
console.log(`ID: ${newUser.id}`);
console.log(`Name: ${newUser.name}`);
console.log(`Email: ${newUser.email}`);
} catch (error) {
console.error('Error creating user:', error);
process.exit(1);
}
} else {
console.log('Available commands:');
console.log(' createUser <name> <email> - Create a new user');
}
await prisma.$disconnect(); // Prisma Clientを明示的に切断
}
main().catch(console.error);
ビルドして実行
docker compose up --build
# CLIでユーザーを作成
docker compose exec app bun run src/cli.ts createUser "CLI User" "cli@example.com"
データが入ったかを確認
docker compose exec db mysql -u root -prootpassword testdb -e "SELECT * FROM users;"
+--------------------------------------+-------------------------+-------------+
| id | email | name |
+--------------------------------------+-------------------------+-------------+
| 8bff1ed3-4e49-4761-a4aa-6b7e193ea360 | cli@example.com | CLI User |
| de1ea905-7b9f-4792-9ffd-b6c5b25d6527 | prisma.user@example.com | Prisma User |
+--------------------------------------+-------------------------+-------------+
このようにcliツールとしてもデータベースに格納できた
Prismaを導入することで、特にMySQLとの連携において型安全で効率的な開発が可能になる。クリーンアーキテクチャでは、このPrismaを使った実装(MysqlUserRepository
)と、全く異なる技術(Redis)を使った実装(RedisUserRepository
)を、ビジネスロジック(Usecase)に一切変更を加えることなく差し替えられる。これが抽象に依存することの真価である。プロジェクトの要件に応じて適切なアーキテクチャとツールを選択することが、持続可能な開発の鍵となるでしょう.