1. なぜレイヤードアーキテクチャが必要なのか?

酒本先輩!
第11回で Zod を使ってリクエストのバリデーションがミドルウェアとして分離できるようになりました!
ただ、今までは src/index.ts の中に DB 操作やビジネスロジック、ルーティングまで全部詰め込んでいたので、コードが数百行に膨らんじゃって…。

いいところに気づいたわね、熊木くん!
1つのファイルや関数に『HTTPリクエストの処理』『ビジネスロジック(計算や判定)』『データアクセス(DB操作)』を混ぜて書く設計(いわゆるファットコントローラーやモノリシックファイル)は、アンチパターンの代表例なの。
この書き方には、実務で深刻な3つの問題があるわ。
- 可読性の破綻: どこで何をしているのかを探すコードリーディングに膨大な時間がかかる。
- テスト不可能: データベースや外部 API と強く結合しているため、ビジネスロジック単体のユニットテストが書けない。
- 変更への弱さ: 「DBを PostgreSQL から MongoDB に変えたい」「Webフレームワークを Express から Hono に変えたい」となった時、システム全体を書き直す羽目になる。

これを解決するのが Layered Architecture(レイヤードアーキテクチャ) よ。
コードの役割ごとに『層(レイヤー)』を分け、依存関係の方向を一方通行に整理する設計思想なの!
【クライアント】
│ HTTP リクエスト / レスポンス
▼
┌──────────────────────────┐
│ 1. Presentation Layer (Controller / Router) │
│ ・HTTP のリクエスト受付、ステータスコード返却 │
│ ・Zod スキーマバリデーションの呼び出し │
└──────────────────────────┘
│ (呼び出し)
▼
┌───────────────────────────────┐
│ 2. Business Layer (Service) │
│ ・純粋なビジネスロジック(合計金額計算、割引適用など) │
│ ・HTTP (req/res) や DB の詳細を一切知らない │
└───────────────────────────────┘
│ (呼び出し)
▼
┌───────────────────────────────┐
│ 3. Data Access Layer (Repository) │
│ ・データベース(Prisma/メモリ/外部API)との通信 │
└───────────────────────────────┘
2. DI(依存性の注入)と Interface による疎結合化

層を分けるのは理解できました!
でも、Service クラスの中で直接 new CartRepository() ってインスタンス化しちゃダメなんですか?

そこが極めて重要なポイントよ!クラス内で直接 new してしまうと、Service が特定のリポジトリ実装(具象クラス)に固執してしまうの(密結合)。
テストの時に『本物の DB に接続せずに、メモリ上のテストデータでテストしたい』という場合に対応できなくなるわ。
そこで登場するのが Interface(インターフェース) と DI(Dependency Injection:依存性の注入) よ!
【密結合な構成 (直接 new)】
[ CartService ] ─(直接依存)─► [ CartRepository (本物DB) ]
※ テスト時にも本物の DB が必要になってしまう!
【Interface + DI による疎結合な構成】
[ CartService ] ─(インターフェースに依存)─► ≪ ICartRepository ≫
▲
┌──────────────────────┴─────┐
│ (実装) │ (実装)
[ CartRepository (本物DB) ] [ MockCartRepository (テスト用) ]
3. 実務標準のフォルダ構造と環境構築
src/app.ts を直接起動する構成と最新の安定版パッケージ構成に合わせたプロジェクトを作成します。
ディレクトリ構造
my-layered-app/
├── package.json
├── tsconfig.json
├── src/
│ ├── types/ # ドメイン・型定義
│ │ └── cart.type.ts
│ ├── schemas/ # Zod バリデーションスキーマ定義
│ │ └── cart.schema.ts
│ ├── middlewares/ # 共通ミドルウェア(Zod検証, エラーハンドリング)
│ │ ├── validate.middleware.ts
│ │ └── error.middleware.ts
│ ├── repositories/ # Data Access Layer
│ │ ├── interfaces/ # リポジトリの抽象インターフェース
│ │ │ └── cart.repository.interface.ts
│ │ └── memory/ # メモリモック実装(第13回で Prisma に置換)
│ │ └── cart.repository.ts
│ ├── services/ # Business Layer (純粋なビジネスロジック)
│ │ └── cart.service.ts
│ ├── controllers/ # Presentation Layer (HTTP入出力処理)
│ │ └── cart.controller.ts
│ ├── routes/ # ルーティング定義と DI(依存性の注入)組み立て
│ │ └── cart.route.ts
│ ├── app.ts # エントリーポイント・Express アプリケーション起動
│ └── tests/ # 単体テストコード
│ └── cart.service.test.ts
パッケージ構成 (package.json)
tsx を用いて src/app.ts を直接起動する構成です。
{
"name": "my-layered-app",
"version": "1.0.0",
"type": "module",
"main": "src/app.ts",
"scripts": {
"dev": "tsx watch src/app.ts",
"start": "tsx src/app.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"express": "^5.2.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^26.0.0",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vitest": "^4.1.11"
}
}
TypeScript 設定 (tsconfig.json)
ES Modules ("type": "module") 仕様に適合させた設定です。
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}
4. 完全ビルド可能ソースコード一覧 (ESM対応)
※ Node.js の ES Modules ("type": "module") 仕様に準拠し、相対パスのインポートには .js 拡張子を付与しています。
① 型定義 (src/types/cart.type.ts)
// src/types/cart.type.ts
export interface CartItem {
id: number;
productName: string;
price: number;
quantity: number;
couponCode?: string;
}
export interface CartSummary {
items: CartItem[];
totalCount: number;
totalPrice: number;
appliedDiscountTotal: number;
}
② Zod スキーマ定義 (src/schemas/cart.schema.ts)
// src/schemas/cart.schema.ts
import { z } from 'zod';
// カート追加用スキーマ (.transform と .refine を適用)
export const createCartItemSchema = z.object({
productName: z.string().min(1, '商品名は必須です。'),
price: z.number().positive('価格は正の数である必要があります。'),
quantity: z.number().int().positive().optional().default(1),
couponCode: z.string().trim().toUpperCase().optional(),
discountRate: z.number().min(0).max(1).optional().default(0),
maxDiscountAmount: z.number().positive().optional().default(5000),
}).refine((data) => {
if (data.discountRate > 0.5 && data.maxDiscountAmount > 10000) {
return false;
}
return true;
}, {
message: '50%を超える高還元率の場合、最大割引額は 10,000 円以下に制限されます。',
path: ['maxDiscountAmount'],
});
// 数量更新用スキーマ
export const updateCartItemSchema = z.object({
quantity: z.number().int().positive('数量は1以上の整数である必要があります。'),
});
// クエリーパラメータ用スキーマ
export const getCartQuerySchema = z.object({
limit: z.coerce.number().int().positive().optional().default(10),
search: z.string().optional(),
});
// パスパラメータ用スキーマ
export const cartItemIdParamSchema = z.object({
id: z.coerce.number().int().positive('IDは1以上の正の整数である必要があります。'),
});
export type CreateCartItemInput = z.output<typeof createCartItemSchema>;
③ ミドルウェア類 (src/middlewares/)
バリデーションミドルウェア (src/middlewares/validate.middleware.ts)
// src/middlewares/validate.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
interface RequestValidationSchemas {
body?: ZodSchema;
query?: ZodSchema;
params?: ZodSchema;
}
export const validateRequest = (schemas: RequestValidationSchemas) => {
return (req: Request, res: Response, next: NextFunction): void => {
try {
if (schemas.body) {
req.body = schemas.body.parse(req.body);
}
if (schemas.query) {
const parsedQuery = schemas.query.parse(req.query);
for (const key in req.query) {
delete (req.query as any)[key];
}
Object.assign(req.query as any, parsedQuery);
}
if (schemas.params) {
const parsedParams = schemas.params.parse(req.params);
Object.assign(req.params as any, parsedParams);
}
next();
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
rule: issue.code,
}));
res.status(400).json({
error: 'VALIDATION_ERROR',
message: 'リクエストデータに不備があります。',
details: formattedErrors,
});
return;
}
next(error);
}
};
};
エラーハンドリングミドルウェア (src/middlewares/error.middleware.ts)
// src/middlewares/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
export const errorHandler = (
err: any,
req: Request,
res: Response,
next: NextFunction
): void => {
if (err instanceof SyntaxError && 'status' in err && err.status === 400 && 'body' in err) {
res.status(400).json({
error: 'INVALID_JSON_SYNTAX',
message: 'リクエストの JSON 構文が正しくありません。フォーマットを確認してください。',
});
return;
}
console.error('[Unhandled Error]:', err);
res.status(500).json({
error: 'INTERNAL_SERVER_ERROR',
message: 'サーバー内部で予期しないエラーが発生しました。',
});
};
④ Repository Layer (src/repositories/)
インターフェース定義 (src/repositories/interfaces/cart.repository.interface.ts)
// src/repositories/interfaces/cart.repository.interface.ts
import { CartItem } from '../../types/cart.type.js';
export interface ICartRepository {
findAll(): Promise<CartItem[]>;
findById(id: number): Promise<CartItem | undefined>;
create(data: Omit<CartItem, 'id'>): Promise<CartItem>;
updateQuantity(id: number, quantity: number): Promise<CartItem | undefined>;
delete(id: number): Promise<boolean>;
}
メモリ用具象クラス (src/repositories/memory/cart.repository.ts)
// src/repositories/memory/cart.repository.ts
import { ICartRepository } from '../interfaces/cart.repository.interface.js';
import { CartItem } from '../../types/cart.type.js';
export class MemoryCartRepository implements ICartRepository {
private cartItems: CartItem[] = [
{ id: 1, productName: 'エルゴノミクス マウス', price: 8800, quantity: 1, couponCode: 'WELCOME1000' },
{ id: 2, productName: 'メカニカルキーボード', price: 15400, quantity: 2 },
];
private nextId = 3;
async findAll(): Promise<CartItem[]> {
return this.cartItems;
}
async findById(id: number): Promise<CartItem | undefined> {
return this.cartItems.find((item) => item.id === id);
}
async create(data: Omit<CartItem, 'id'>): Promise<CartItem> {
const newItem: CartItem = {
id: this.nextId++,
...data,
};
this.cartItems.push(newItem);
return newItem;
}
async updateQuantity(id: number, quantity: number): Promise<CartItem | undefined> {
const item = await this.findById(id);
if (!item) return undefined;
item.quantity = quantity;
return item;
}
async delete(id: number): Promise<boolean> {
const index = this.cartItems.findIndex((item) => item.id === id);
if (index === -1) return false;
this.cartItems.splice(index, 1);
return true;
}
}
⑤ Service Layer (src/services/cart.service.ts)
// src/services/cart.service.ts
import { ICartRepository } from '../repositories/interfaces/cart.repository.interface.js';
import { CartItem, CartSummary } from '../types/cart.type.js';
export class CartService {
// コンストラクタで ICartRepository インターフェースを注入 (DI)
constructor(private cartRepository: ICartRepository) {}
async getCartSummary(limit?: number, search?: string): Promise<CartSummary> {
let items = await this.cartRepository.findAll();
if (search) {
items = items.filter((item) => item.productName.includes(search));
}
if (limit) {
items = items.slice(0, limit);
}
// 小計の計算
const rawTotalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// ビジネスロジック:特定クーポン適用時の割引計算
let appliedDiscountTotal = 0;
items.forEach((item) => {
if (item.couponCode === 'WELCOME1000') {
appliedDiscountTotal += 1000;
} else if (item.couponCode === 'SPECIAL2026') {
appliedDiscountTotal += Math.floor(item.price * item.quantity * 0.1); // 10% OFF
}
});
const finalTotalPrice = Math.max(0, rawTotalPrice - appliedDiscountTotal);
return {
items,
totalCount: items.length,
totalPrice: finalTotalPrice,
appliedDiscountTotal,
};
}
async addItem(data: Omit<CartItem, 'id'>): Promise<CartItem> {
return await this.cartRepository.create(data);
}
async updateItemQuantity(id: number, quantity: number): Promise<CartItem> {
const updatedItem = await this.cartRepository.updateQuantity(id, quantity);
if (!updatedItem) {
throw new Error('ITEM_NOT_FOUND');
}
return updatedItem;
}
async removeItem(id: number): Promise<void> {
const success = await this.cartRepository.delete(id);
if (!success) {
throw new Error('ITEM_NOT_FOUND');
}
}
}
⑥ Controller Layer (src/controllers/cart.controller.ts)
// src/controllers/cart.controller.ts
import { Request, Response, NextFunction } from 'express';
import { CartService } from '../services/cart.service.js';
export class CartController {
constructor(private cartService: CartService) {}
getSummary = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { limit, search } = req.query as unknown as { limit?: number; search?: string };
const summary = await this.cartService.getCartSummary(limit, search);
res.status(200).json(summary);
} catch (error) {
next(error);
}
};
createItem = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { productName, price, quantity, couponCode } = req.body;
const newItem = await this.cartService.addItem({
productName,
price,
quantity,
couponCode,
});
res.status(201).json(newItem);
} catch (error) {
next(error);
}
};
updateQuantity = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { id } = req.params as unknown as { id: number };
const { quantity } = req.body;
const updatedItem = await this.cartService.updateItemQuantity(id, quantity);
res.status(200).json(updatedItem);
} catch (error) {
if (error instanceof Error && error.message === 'ITEM_NOT_FOUND') {
res.status(404).json({ error: 'NOT_FOUND', message: '指定されたアイテムが見つかりません。' });
return;
}
next(error);
}
};
deleteItem = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
const { id } = req.params as unknown as { id: number };
await this.cartService.removeItem(id);
res.status(204).send();
} catch (error) {
if (error instanceof Error && error.message === 'ITEM_NOT_FOUND') {
res.status(404).json({ error: 'NOT_FOUND', message: '指定されたアイテムが見つかりません。' });
return;
}
next(error);
}
};
}
⑦ Route & DI 組み立て (src/routes/cart.route.ts)
// src/routes/cart.route.ts
import { Router } from 'express';
import { CartController } from '../controllers/cart.controller.js';
import { CartService } from '../services/cart.service.js';
import { MemoryCartRepository } from '../repositories/memory/cart.repository.js';
import { validateRequest } from '../middlewares/validate.middleware.js';
import {
createCartItemSchema,
updateCartItemSchema,
getCartQuerySchema,
cartItemIdParamSchema,
} from '../schemas/cart.schema.js';
// 依存関係の組み立て (Dependency Injection)
const cartRepository = new MemoryCartRepository();
const cartService = new CartService(cartRepository);
const cartController = new CartController(cartService);
const router = Router();
router.get(
'/items',
validateRequest({ query: getCartQuerySchema }),
cartController.getSummary
);
router.post(
'/items',
validateRequest({ body: createCartItemSchema }),
cartController.createItem
);
router.patch(
'/items/:id',
validateRequest({ params: cartItemIdParamSchema, body: updateCartItemSchema }),
cartController.updateQuantity
);
router.delete(
'/items/:id',
validateRequest({ params: cartItemIdParamSchema }),
cartController.deleteItem
);
export default router;
⑧ エントリーポイント・アプリケーション起動 (src/app.ts)
// src/app.ts
import express, { Application, Request, Response } from 'express';
import cartRouter from './routes/cart.route.js';
import { errorHandler } from './middlewares/error.middleware.js';
const app: Application = express();
const PORT = 3000;
// ボディパーサーミドルウェア
app.use(express.json());
// アプリケーションルーティングのバインド
app.use('/api/cart', cartRouter);
// ヘルスチェック用エンドポイント
app.get('/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'UP', timestamp: new Date().toISOString() });
});
// ルート未定義ハンドラー (404)
app.use((req: Request, res: Response) => {
res.status(404).json({
error: 'NOT_FOUND',
message: '要求されたエンドポイントが存在しません。',
});
});
// 共通エラーハンドリングミドルウェア(必ず最後に配置)
app.use(errorHandler);
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
console.log(`[Server] App running on http://localhost:${PORT}`);
});
}
export default app;
5. curl による完全動作検証手順
アプリケーションを起動して実世界の想定動作を検証します。
# 依存関係をインストール
npm install
# src/app.ts を直接起動
npm run dev
① データの新規追加(.transform() によるクーポン自動整形と検証)
curl -i -X POST http://localhost:3000/api/cart/items \
-H "Content-Type: application/json" \
-d '{"productName": "4K液晶モニター", "price": 45000, "couponCode": "special2026 "}'
レスポンス (201 Created):
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{"id":3,"productName":"4K液晶モニター","price":45000,"quantity":1,"couponCode":"SPECIAL2026"}
② 一覧取得と割引計算ビジネスロジックの検証
curl -i "http://localhost:3000/api/cart/items?limit=10"
レスポンス (200 OK):
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{
"items": [
{ "id": 1, "productName": "エルゴノミクス マウス", "price": 8800, "quantity": 1, "couponCode": "WELCOME1000" },
{ "id": 2, "productName": "メカニカルキーボード", "price": 15400, "quantity": 2 },
{ "id": 3, "productName": "4K液晶モニター", "price": 45000, "quantity": 1, "couponCode": "SPECIAL2026" }
],
"totalCount": 3,
"totalPrice": 79100,
"appliedDiscountTotal": 5500
}
6. レイヤードアーキテクチャが真価を発揮する「単体テスト」の例

この設計がいかに素晴らしいか、テストコードを見てみましょう。
CartService のテストを書く際、データベースや Express は一切必要ありません!
ビジネスロジックの単体テスト例 (src/tests/cart.service.test.ts)
// src/tests/cart.service.test.ts
import { CartService } from '../services/cart.service.js';
import { ICartRepository } from '../repositories/interfaces/cart.repository.interface.js';
import { CartItem } from '../types/cart.type.js';
// テスト用のダミーリポジトリ(Mock)を作成
class MockCartRepository implements ICartRepository {
private items: CartItem[] = [
{ id: 1, productName: 'テスト商品A', price: 1000, quantity: 2, couponCode: 'WELCOME1000' }, // 2000円 - 1000円割引 = 1000円
{ id: 2, productName: 'テスト商品B', price: 3000, quantity: 1 }, // 3000円
];
async findAll(): Promise<CartItem[]> {
return this.items;
}
async findById(id: number): Promise<CartItem | undefined> {
return this.items.find((item) => item.id === id);
}
async create(data: Omit<CartItem, 'id'>): Promise<CartItem> {
const newItem = { id: 99, ...data };
this.items.push(newItem);
return newItem;
}
async updateQuantity(id: number, quantity: number): Promise<CartItem | undefined> {
const item = await this.findById(id);
if (!item) return undefined;
item.quantity = quantity;
return item;
}
async delete(id: number): Promise<boolean> {
return true;
}
}
// テスト実行関数
async function runTests() {
console.log('--- 🧪 CartService 単体テスト開始 ---');
// 1. DI(Mockリポジトリを注入)
const mockRepo = new MockCartRepository();
const cartService = new CartService(mockRepo);
// 2. クーポン計算と合計金額の検証
const summary = await cartService.getCartSummary();
console.assert(summary.totalCount === 2, '❌ totalCount が不正です');
console.assert(summary.appliedDiscountTotal === 1000, '❌ 割引額が違います (想定: 1000)');
console.assert(summary.totalPrice === 4000, '❌ 割引後の合計金額が違います (想定: 4000)');
// 3. 検索機能の検証
const filteredSummary = await cartService.getCartSummary(undefined, '商品A');
console.assert(filteredSummary.items.length === 1, '❌ 検索フィルタが正しく機能していません');
console.log('✅ すべての単体テストをパスしました!');
}
runTests().catch((err) => {
console.error('❌ テスト実行失敗:', err);
process.exit(1);
});
実行コマンド:
npm run test
本日のまとめ
- モジュール性と単一責任原則: 各ファイルを 1 つの責務(Controller / Service / Repository)に限定することで可読性が爆発的に向上する。
- DI による高い柔軟性:
CartServiceはインターフェースに依存しているため、第13回で DB(Prisma)を導入する際もsrc/routes/cart.route.tsで注入するインスタンスを差し替えるだけで完了する。 - 高効率な単体テスト: レイヤーを独立させることで、データベースやネットワークに依存しない高速で堅牢な単体テストが記述可能になる。
次回:第13回「Prisma ORM の導入と PostgreSQL データベース接続・マイグレーション」へ続く

