【Node.js実戦習得】第12回:Layered Architecture(レイヤードアーキテクチャ)による関心の分離とフォルダ設計

社員ブログ

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 データベース接続・マイグレーション」へ続く

タイトルとURLをコピーしました