はじめに

この記事では、TypeScriptでクリーンアーキテクチャを実装する方法を紹介します。実際にプロジェクトで使っている構成ですが、コードはサンプルコードに置き換えています。

セクションとしては以下のような感じでそれぞれ実装パターンを紹介します:

  • 関数型スタイルの依存性注入(DI)
  • neverthrowを使った関数的エラーハンドリング
  • dependency-cruiserによる静的な依存関係の検証
  • ドメイン駆動設計(DDD)

アーキテクチャ全体像

src/
├── core/          # ドメイン層(ビジネスロジック)
│   ├── @shared/   # 共通インフラ(全ドメインから参照可能)
│   ├── order/     # 注文ドメイン
│   ├── product/   # 商品ドメイン
│   ├── customer/  # 顧客ドメイン
│   └── ...
├── routes/        # プレゼンテーション層(HTTPハンドラー)
├── query/         # クエリ層(読み取り専用)
└── pubsub/        # イベント駆動システム

依存関係のルール

クリーンアーキテクチャのあの円のまんまを実装し、ドメインレイヤー(src/core配下)は外のI/Oレイヤーに依存しないように設計しています。また、coreの中でも以下のルールを厳格に適用し、外部モジュールとの疎結合を維持しています:

  1. ドメイン間の分離: 各ドメインは@shared以外の他ドメインに直接依存しない
  2. インターフェースファイルの純粋性: $interface.tsは自ドメイン以外への依存を持たない

これらのルールは.dependency-cruiser.cjsで定義され、CIで自動検証されます。

// .dependency-cruiser.cjs(抜粋)
{
  name: "core-modules-restricted-imports",
  severity: "error",
  comment: "src/core配下は外部レイヤーに依存できません",
  from: { path: "^src/core/(?!@shared)[^/]+/" },
  to: { path: "^src/(?!core/|generated/|pubsub/|lib/)|\\$impl\\.ts$" }
}

ドメイン層の実装パターン

各ドメインは以下の3つのファイルで構成されます:

1. $interface.ts - 依存関係の抽象化

外部依存(DBアクセス、外部API等)を型として定義します。この時I/Fはなるべくドメインの知識を含まないように切ります。例えばユーザーの10,000円以上の注文明細を取得したい時、findRichOrdersみたいにどんな注文かを表すI/Fではなく、findOrdersByAmountなどとし、インターフェースの実装がドメイン知識を含まないような名前にすることを心がけます。 そうすることでI/Oのような外部依存のコードの処理の責務を最小限に抑えることができ、ドメインモデル側で検証できる範囲が広くなります(=広い範囲をテストできるので品質が上がる)。 ただし、I/Fの実装自体にもテストサイクルを回すことができるのであればドメイン知識を含める判断も無きにしも非ずかなとは思います(この選択をとったことがないのでわからないです)。I/Fにドメイン知識を含めることはドメインモデルの一部知識のテストを諦める選択肢なのでそこを取捨選択して判断します。

自分の場合、レポジトリ層は作らずに、I/FでDBアクセスを抽象化しています。 レポジトリパターンで抽象できると一々実装も書かなくて良いので楽になるのですが、DDDの文脈の集約を決定する必要が出てくるかと思います。 自分はここを高い確度で再現性高く当てられる自信がまだないので、あえてただのI/OのI/Fを使っています。

// core/order/$interface.ts
import type { ResultAsync } from "neverthrow";
import type { IOError } from "~/core/@shared/utils";
 
export type Order = {
  id: number;
  customerId: number;
  totalAmount: number;
  status: "pending" | "confirmed" | "shipped";
};
 
export type FindOrderById = (
  orderId: number
) => ResultAsync<Order | null, IOError>;
 
export type SaveOrder = (data: {
  customerId: number;
  items: Array<{ productId: number; quantity: number }>;
}) => ResultAsync<Order, IOError>;
 
export type UpdateOrderStatus = (
  orderId: number,
  status: Order["status"]
) => ResultAsync<Order, IOError>;

重要なポイント:

  • 戻り値は必ずResultAsync<T, E>型(例外を投げない)
  • 具体的な実装(ORMクライアント等)には一切依存しない
  • 型定義のみで実装を含まない(たまにzodのschema作っちゃう)

2. order.ts - 純粋なビジネスロジック

ドメインの核となる業務ルールを実装します。外部依存は全て引数として受け取ります。 neverthrowを使って書くのですが、慣れないうちは型パズルに悩まされることが多いです。

// core/order/order.ts
import { errAsync, okAsync } from "neverthrow";
import type { z } from "zod";
 
export class InsufficientStockError extends Error {
  constructor() {
    super("在庫が不足しています");
  }
}
 
export class CustomerNotFoundError extends Error {
  constructor() {
    super("顧客が見つかりません");
  }
}
 
export const placeOrder =
  (
    findCustomer: FindCustomer,
    checkStock: CheckStock,
    saveOrder: SaveOrder,
    reserveStock: ReserveStock,
    sendNotification: SendNotification,
    publisher: EventPublisher
  ) =>
  (rawRequest: z.input<typeof placeOrderSchema>) =>
    $parseWithZod(placeOrderSchema, rawRequest)
      .asyncAndThen((request) =>
        // 顧客の存在確認
        findCustomer(request.customerId).andThen((customer) => {
          if (!customer) {
            return errAsync(new CustomerNotFoundError());
          }
          return okAsync({ request, customer });
        })
      )
      .andThen(({ request, customer }) =>
        // 在庫チェック
        checkStock(request.items).andThen((stockResult) => {
          if (!stockResult.available) {
            return errAsync(new InsufficientStockError());
          }
          return okAsync({ request, customer, stockResult });
        })
      )
      .andThen(({ request, customer, stockResult }) =>
        // 注文の保存
        saveOrder({
          customerId: customer.id,
          items: request.items,
        })
          // 在庫予約
          .andThen((order) =>
            reserveStock(request.items).map(() => ({ order, customer }))
          )
          // 通知送信
          .andThen(({ order, customer }) =>
            sendNotification({
              to: customer.email,
              template: "order_confirmation",
              data: { orderId: order.id },
            }).map(() => order)
          )
          // イベント発行
          .andThen((order) =>
            publisher
              .publish("order.placed", {
                orderId: order.id,
                customerId: customer.id,
              })
              .map(() => order)
          )
      );

このパターンの利点:

  1. カリー化による段階的な依存注入: 最初の関数で依存を注入し、次の関数でリクエストを受け取る
  2. Railway Oriented Programming: andThenチェーンでエラーハンドリングを自然に記述
  3. 完全なユニットテスト可能性: 全ての依存をモックして純粋にロジックをテスト可能

3. $impl.ts - 具体的な実装

抽象化された依存関係の実装を提供します。ORMクライアントなどのインフラ層との結合点です。

// core/order/$impl.ts
import type { InjectDependency } from "~/core/@shared/context";
import { $fromPromise, IOError } from "~/core/@shared/utils";
import type { FindOrderById, SaveOrder } from "~/core/order/$interface";
 
export const findOrderByIdImpl: InjectDependency<FindOrderById> =
  (context) => (orderId) =>
    $fromPromise(
      context.prisma.order.findUnique({
        where: { id: orderId },
      }),
      (e) => new IOError("注文の検索に失敗しました", e)
    );
 
export const saveOrderImpl: InjectDependency<SaveOrder> = (context) => (data) =>
  $fromPromise(
    context.prisma.order.create({
      data: {
        customerId: data.customerId,
        items: {
          create: data.items.map((item) => ({
            productId: item.productId,
            quantity: item.quantity,
          })),
        },
      },
    }),
    (e) => new IOError("注文の作成に失敗しました", e)
  );

InjectDependency型の定義:

// core/@shared/context.ts
export interface AppContext {
  prisma: PrismaClient;
  publisher: EventPublisher;
  logger: Logger;
}
 
export type InjectDependency<T> = (context: AppContext) => T;

このパターンにより、実装はAppContextを受け取って具体的な関数を返すという統一されたインターフェースを持ちます。

プレゼンテーション層との統合

HTTPハンドラー(routes層)では、ドメインロジックに依存を注入して実行します。 neverthrowのおかげでエラーハンドリングが型安全に行えます。lintのswitchを必ず全て網羅するルールと掛け合わせることで、全てのエラーケースを確実にハンドリングできます。

// routes/order/index.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { findOrderByIdImpl, saveOrderImpl } from "~/core/order/$impl";
import { placeOrder, placeOrderSchema } from "~/core/order/order";
import { contextFactoryMiddleware } from "~/lib/hono";
 
const app = new Hono().post(
  "/orders",
  zValidator("json", placeOrderSchema, validationErrorHandler),
  contextFactoryMiddleware({ needTransaction: true }),
  async (c) => {
    const context = c.get("context");
    const request = c.req.valid("json");
 
    const workflow = placeOrder(
      findCustomerImpl(context),
      checkStockImpl(context),
      saveOrderImpl(context),
      reserveStockImpl(context),
      sendNotificationImpl(context),
      context.publisher
    );
 
    const result = await workflow(request);
 
    return result.match(
      (result) => c.json(result, 201),
      async (error) => {
        switch (error.name) {
          case "InsufficientStockError":
            return c.json({ message: "在庫が不足しています" }, 400);
          case "CustomerNotFoundError":
            return c.json({ message: "顧客が見つかりません" }, 404);
          case "ValidationError":
            return c.json({ message: error.message }, 400);
          case "IOError":
            throw error;
        }
      }
    );
  }
);

テスト戦略

ユニットテスト: モックによる完全な分離

// core/order/order.test.ts
import { describe, it, expect, vi } from "vitest";
import { okAsync, errAsync } from "neverthrow";
import { placeOrder, InsufficientStockError } from "./order";
 
describe("placeOrder", () => {
  it("在庫が不足している場合はエラーを返す", async () => {
    // モック作成
    const mockFindCustomer = vi.fn(() =>
      okAsync({ id: 1, name: "山田太郎", email: "yamada@example.com" })
    );
    const mockCheckStock = vi.fn(() => okAsync({ available: false }));
    const mockSaveOrder = vi.fn();
    const mockReserveStock = vi.fn();
    const mockSendNotification = vi.fn();
    const mockPublisher = { publish: vi.fn(() => okAsync(undefined)) };
 
    // ワークフロー実行
    const workflow = placeOrder(
      mockFindCustomer,
      mockCheckStock,
      mockSaveOrder,
      mockReserveStock,
      mockSendNotification,
      mockPublisher
    );
 
    const result = await workflow({
      customerId: 1,
      items: [{ productId: 100, quantity: 10 }],
    });
 
    // 検証
    expect(result.isErr()).toBe(true);
    expect(result._unsafeUnwrapErr()).toBeInstanceOf(InsufficientStockError);
    expect(mockSaveOrder).not.toHaveBeenCalled();
  });
 
  it("正常な注文フローが完了する", async () => {
    const mockOrder = {
      id: 1,
      customerId: 1,
      totalAmount: 10000,
      status: "pending",
    };
    const mockFindCustomer = vi.fn(() =>
      okAsync({ id: 1, name: "山田太郎", email: "yamada@example.com" })
    );
    const mockCheckStock = vi.fn(() => okAsync({ available: true }));
    const mockSaveOrder = vi.fn(() => okAsync(mockOrder));
    const mockReserveStock = vi.fn(() => okAsync(undefined));
    const mockSendNotification = vi.fn(() => okAsync(undefined));
    const mockPublisher = { publish: vi.fn(() => okAsync(undefined)) };
 
    const workflow = placeOrder(
      mockFindCustomer,
      mockCheckStock,
      mockSaveOrder,
      mockReserveStock,
      mockSendNotification,
      mockPublisher
    );
 
    const result = await workflow({
      customerId: 1,
      items: [{ productId: 100, quantity: 2 }],
    });
 
    expect(result.isOk()).toBe(true);
    expect(mockSaveOrder).toHaveBeenCalledTimes(1);
    expect(mockReserveStock).toHaveBeenCalledTimes(1);
    expect(mockPublisher.publish).toHaveBeenCalledWith("order.placed", {
      orderId: 1,
      customerId: 1,
    });
  });
});

全ての依存が注入されるため、完全に純粋なロジックのテストが可能です。

E2Eテスト: Testcontainersによる統合テスト

実際のデータベースコンテナを使用して、エンドツーエンドの動作を検証します。

// tests/e2e/order.test.ts
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { PrismaClient } from "@prisma/client";
 
describe("Order E2E Tests", () => {
  let container: PostgreSqlContainer;
  let prisma: PrismaClient;
 
  beforeAll(async () => {
    container = await new PostgreSqlContainer().start();
    prisma = new PrismaClient({
      datasources: { db: { url: container.getConnectionString() } },
    });
  });
 
  afterAll(async () => {
    await prisma.$disconnect();
    await container.stop();
  });
 
  it("注文フロー全体が正常に動作する", async () => {
    // 実際のDBを使った統合テスト
    const customer = await prisma.customer.create({
      data: { name: "テスト太郎", email: "test@example.com" },
    });
 
    const product = await prisma.product.create({
      data: { name: "商品A", price: 1000, stock: 100 },
    });
 
    // 実際のAPIエンドポイントを呼び出してテスト
    const response = await fetch("/api/orders", {
      method: "POST",
      body: JSON.stringify({
        customerId: customer.id,
        items: [{ productId: product.id, quantity: 2 }],
      }),
    });
 
    expect(response.status).toBe(201);
    const data = await response.json();
    expect(data.orderId).toBeDefined();
  });
});

まとめ

このアーキテクチャの重要なポイント:

1. 依存関係の厳格な管理

  • dependency-cruiserによる静的検証
  • ドメイン層の完全な独立性
  • $interface.tsによる抽象化

2. 関数型スタイルのDI

  • カリー化による段階的な依存注入
  • InjectDependency<T>型による統一されたパターン
  • テスタビリティの向上

3. Result型によるエラーハンドリング

  • 例外を投げない関数設計
  • 型安全なエラーハンドリング
  • Railway Oriented Programmingパターン

このアプローチにより、保守性が高く、テスタブルで、長期的な変更に強いコードベースを実現しています。

参考リンク


この記事が、TypeScriptでクリーンアーキテクチャを実装する際の参考になれば幸いです。