T-CREATOR

Bun でクリーンアーキテクチャ:ドメイン分離・DI・テスト容易性の設計指針

Bun でクリーンアーキテクチャ:ドメイン分離・DI・テスト容易性の設計指針

Bun は高速な JavaScript ランタイムとして注目を集めていますが、アプリケーションの規模が大きくなるにつれて、保守性や拡張性の課題に直面します。この記事では、Bun でクリーンアーキテクチャを採用し、ドメイン分離・依存性注入(DI)・テスト容易性を実現する設計指針をご紹介します。

実際のコード例を交えながら、初心者の方でも理解できるよう、段階的に解説していきますね。クリーンアーキテクチャの基本概念から、Bun の特性を活かした実装パターンまで、実践的な知識を身につけていただけます。

背景

クリーンアーキテクチャとは

クリーンアーキテクチャは、Robert C. Martin(通称 Uncle Bob)が提唱したソフトウェア設計の原則です。ビジネスロジックを中心に据え、外部の技術的詳細から独立させることで、変更に強いシステムを構築できます。

主な特徴として、以下の点が挙げられます。

  • 依存関係の方向性: 外側の層から内側の層へ一方向に依存する
  • ビジネスロジックの独立性: フレームワークやデータベースに依存しない
  • テスト容易性: モックやスタブを使った単体テストが容易

以下の図は、クリーンアーキテクチャの層構造を示しています。

mermaidflowchart TB
  subgraph outer["外部層(Frameworks & Drivers)"]
    web["Web/API<br/>Controller"]
    db["Database<br/>実装"]
  end

  subgraph interface["インターフェース層(Interface Adapters)"]
    presenter["Presenter"]
    repository["Repository<br/>実装"]
  end

  subgraph usecase["ユースケース層(Application Business Rules)"]
    uc["UseCase"]
  end

  subgraph domain["ドメイン層(Enterprise Business Rules)"]
    entity["Entity"]
    interface_def["Repository<br/>インターフェース"]
  end

  web -->|依存| presenter
  presenter -->|依存| uc
  uc -->|依存| entity
  uc -->|依存| interface_def
  repository -->|実装| interface_def
  db -->|依存| repository

  style domain fill:#e1f5ff
  style usecase fill:#fff4e1
  style interface fill:#ffe1f5
  style outer fill:#f0f0f0

この図から分かるように、依存関係は常に外側から内側へ向かいます。ドメイン層は他のどの層にも依存せず、最も変更に強い設計となっています。

Bun の特性とクリーンアーキテクチャの相性

Bun は従来の Node.js と比較して、以下の特性を持っています。

#特性説明クリーンアーキテクチャへの影響
1高速起動ランタイムの起動が高速テストの実行速度が向上
2ネイティブ TypeScriptトランスパイル不要型安全性を保ちながら開発可能
3組み込みテストランナーbun testコマンド標準搭載テスト環境の構築が簡潔
4高速パッケージ管理依存関係のインストールが高速開発サイクルの短縮

Bun の高速性と TypeScript サポートは、クリーンアーキテクチャにおける型安全性とテスト駆動開発を強力にサポートします。

課題

従来のアプリケーション設計の問題点

Bun を使った開発でも、適切な設計を行わないと以下のような問題が発生します。

ビジネスロジックとインフラの密結合

データベースアクセスや HTTP リクエスト処理が、ビジネスロジックに直接書かれているケースです。

typescript// 問題のあるコード例:ビジネスロジックにDB実装が混在
export async function createUser(
  name: string,
  email: string
) {
  // データベース実装が直接書かれている
  const db = new Database();
  const result = await db.query(
    'INSERT INTO users (name, email) VALUES (?, ?)',
    [name, email]
  );

  return result;
}

このコードには次のような問題があります。

  • データベースの変更時に、ビジネスロジックの修正が必要
  • テスト時に実際のデータベースが必要になる
  • 再利用性が低く、保守が困難

テストが困難な構造

外部サービスや状態に依存したコードは、テストの際にモック化が難しくなります。

typescript// テストが困難なコード例
export class UserService {
  async registerUser(name: string, email: string) {
    // 直接HTTPリクエストを実行
    const response = await fetch(
      'https://api.example.com/validate',
      {
        method: 'POST',
        body: JSON.stringify({ email }),
      }
    );

    // データベースへ直接アクセス
    const db = new Database();
    await db.insert('users', { name, email });
  }
}

この設計では以下の課題が生じます。

  • 外部 API への依存により、テスト実行に時間がかかる
  • ネットワークエラーでテストが失敗する可能性
  • 依存関係の注入ができず、テストダブルが使えない

依存関係の複雑化

各層が相互に依存し、変更の影響範囲が予測できない状態です。

mermaidflowchart LR
  controller["Controller"] <-->|相互依存| service["Service"]
  service <-->|相互依存| repo["Repository"]
  repo <-->|相互依存| model["Model"]
  controller <-->|直接参照| model

  style controller fill:#ffcccc
  style service fill:#ffcccc
  style repo fill:#ffcccc
  style model fill:#ffcccc

相互依存の問題点として、次のことが挙げられます。

  • 一部の変更が予期せぬ箇所に影響する
  • コンポーネントの再利用が困難
  • テスト時に多くの依存関係をセットアップする必要がある

これらの課題を解決するために、クリーンアーキテクチャと DI パターンの導入が有効です。

解決策

ドメイン分離による責務の明確化

クリーンアーキテクチャでは、システムを 4 つの層に分離します。各層の責務を明確にすることで、保守性と拡張性が向上します。

レイヤー構成

プロジェクト構造は以下のようになります。

graphqlsrc/
├── domain/           # ドメイン層
│   ├── entities/     # エンティティ
│   └── repositories/ # リポジトリインターフェース
├── usecase/          # ユースケース層
│   └── user/         # ユーザー関連のユースケース
├── interface/        # インターフェース層
│   ├── repositories/ # リポジトリ実装
│   └── presenters/   # プレゼンター
└── infrastructure/   # インフラ層
    ├── web/          # HTTPサーバー
    └── database/     # データベース接続

各層の責務を整理すると、次の表のようになります。

#責務依存先
1ドメイン層ビジネスルールの定義なし
2ユースケース層アプリケーション固有のビジネスロジックドメイン層
3インターフェース層データの変換と受け渡しユースケース層、ドメイン層
4インフラ層外部システムとの連携インターフェース層

エンティティの定義

ドメイン層では、ビジネスの中核となるエンティティを定義します。外部の技術には一切依存しません。

typescript// domain/entities/User.ts
export class User {
  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string,
    public readonly createdAt: Date
  ) {
    this.validate();
  }

このエンティティクラスでは、コンストラクタで必要なプロパティを受け取り、インスタンス生成時にバリデーションを行います。

typescript  // バリデーションロジック(ドメインルール)
  private validate(): void {
    if (!this.name || this.name.trim().length === 0) {
      throw new Error("名前は必須です");
    }

    if (!this.isValidEmail(this.email)) {
      throw new Error("有効なメールアドレスを入力してください");
    }
  }

バリデーションはビジネスルールの一部であり、ドメイン層に配置することで、どこで User エンティティを作成しても同じルールが適用されます。

typescript  private isValidEmail(email: string): boolean {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(email);
  }
}

メールアドレスの検証ロジックもエンティティ内にカプセル化されています。

リポジトリインターフェースの定義

データの永続化方法を抽象化するため、インターフェースを定義します。

typescript// domain/repositories/IUserRepository.ts
import { User } from '../entities/User';

export interface IUserRepository {
  findById(id: string): Promise<User | null>;
  findByEmail(email: string): Promise<User | null>;
  save(user: User): Promise<void>;
  delete(id: string): Promise<void>;
}

このインターフェースにより、ユースケース層はデータベースの実装詳細を知る必要がありません。MySQL でも PostgreSQL でも、インターフェースさえ満たせば差し替え可能です。

依存性注入(DI)の実装

依存性注入により、各層の結合度を下げ、テスト容易性を向上させます。

DI コンテナの作成

Bun でシンプルな DI コンテナを実装します。複雑なライブラリを使わず、TypeScript の型システムを活用した設計です。

typescript// infrastructure/di/Container.ts
type Constructor<T> = new (...args: any[]) => T;
type Factory<T> = () => T;

export class Container {
  private services = new Map<string, any>();
  private factories = new Map<string, Factory<any>>();

Container クラスでは、サービスのインスタンスとファクトリー関数を管理します。

typescript  // サービスを登録(シングルトン)
  register<T>(token: string, instance: T): void {
    this.services.set(token, instance);
  }

  // ファクトリーを登録(毎回新しいインスタンスを生成)
  registerFactory<T>(token: string, factory: Factory<T>): void {
    this.factories.set(token, factory);
  }

register メソッドはシングルトンパターン、registerFactory メソッドは毎回新しいインスタンスを生成するパターンに対応しています。

typescript  // サービスを取得
  resolve<T>(token: string): T {
    // ファクトリーが登録されていればそちらを優先
    if (this.factories.has(token)) {
      const factory = this.factories.get(token)!;
      return factory();
    }

    if (this.services.has(token)) {
      return this.services.get(token)!;
    }

    throw new Error(`サービス "${token}" が見つかりません`);
  }
}

resolve メソッドで、登録されたサービスやファクトリーからインスタンスを取得できます。

ユースケースの実装

ユースケース層では、DI コンテナから注入された依存関係を使ってビジネスロジックを実装します。

typescript// usecase/user/CreateUserUseCase.ts
import { User } from "../../domain/entities/User";
import { IUserRepository } from "../../domain/repositories/IUserRepository";

export class CreateUserUseCase {
  constructor(private readonly userRepository: IUserRepository) {}

コンストラクタでリポジトリのインターフェースを受け取ります。具体的な実装クラスではなく、インターフェースに依存することがポイントです。

typescript  async execute(input: CreateUserInput): Promise<CreateUserOutput> {
    // メールアドレスの重複チェック
    const existingUser = await this.userRepository.findByEmail(input.email);
    if (existingUser) {
      throw new Error("このメールアドレスは既に登録されています");
    }

既存ユーザーのチェックは、ビジネスロジックの一部です。リポジトリを通じてデータを取得しますが、取得方法の詳細は知りません。

typescript// Userエンティティの作成(ドメインルールが適用される)
const user = new User(
  this.generateId(),
  input.name,
  input.email,
  new Date()
);

// リポジトリに保存
await this.userRepository.save(user);

User エンティティを生成する際に、自動的にバリデーションが実行されます。保存処理もインターフェースを通じて行われます。

typescript    return {
      id: user.id,
      name: user.name,
      email: user.email,
      createdAt: user.createdAt
    };
  }

  private generateId(): string {
    // UUIDの生成(簡易版)
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
}

ユースケースの出力として、必要な情報だけを返します。

typescript// 入出力の型定義
export interface CreateUserInput {
  name: string;
  email: string;
}

export interface CreateUserOutput {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

入出力の型を明示的に定義することで、ユースケースの契約が明確になります。

リポジトリの実装

インターフェース層で、実際のデータアクセスロジックを実装します。

typescript// interface/repositories/UserRepositoryImpl.ts
import { User } from "../../domain/entities/User";
import { IUserRepository } from "../../domain/repositories/IUserRepository";

export class UserRepositoryImpl implements IUserRepository {
  private users: Map<string, User> = new Map();

この例ではメモリ内にデータを保持していますが、実際のプロジェクトではデータベース接続を行います。

typescript  async findById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }

  async findByEmail(email: string): Promise<User | null> {
    for (const user of this.users.values()) {
      if (user.email === email) {
        return user;
      }
    }
    return null;
  }

findById と findByEmail メソッドで、それぞれ ID とメールアドレスでユーザーを検索します。

typescript  async save(user: User): Promise<void> {
    this.users.set(user.id, user);
  }

  async delete(id: string): Promise<void> {
    this.users.delete(id);
  }
}

save と delete メソッドで、ユーザーの保存と削除を行います。

テスト容易性の向上

DI とインターフェースを活用することで、テストが容易になります。

モックリポジトリの作成

テスト用のモック実装を作成します。

typescript// tests/mocks/MockUserRepository.ts
import { User } from "../../src/domain/entities/User";
import { IUserRepository } from "../../src/domain/repositories/IUserRepository";

export class MockUserRepository implements IUserRepository {
  private users: User[] = [];

  // テスト用のヘルパーメソッド
  setUsers(users: User[]): void {
    this.users = users;
  }

setUsers メソッドで、テストケースごとに異なるデータをセットアップできます。

typescript  async findById(id: string): Promise<User | null> {
    return this.users.find(u => u.id === id) || null;
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.users.find(u => u.email === email) || null;
  }

  async save(user: User): Promise<void> {
    this.users.push(user);
  }

  async delete(id: string): Promise<void> {
    this.users = this.users.filter(u => u.id !== id);
  }
}

インターフェースを実装することで、実装と同じメソッドシグネチャを持ちながら、テスト用の振る舞いを定義できます。

Bun のテストランナーを使用したテスト

Bun 標準のテストランナーを使って、ユースケースのテストを記述します。

typescript// tests/usecase/CreateUserUseCase.test.ts
import { describe, test, expect, beforeEach } from "bun:test";
import { CreateUserUseCase } from "../../src/usecase/user/CreateUserUseCase";
import { MockUserRepository } from "../mocks/MockUserRepository";
import { User } from "../../src/domain/entities/User";

describe("CreateUserUseCase", () => {
  let useCase: CreateUserUseCase;
  let mockRepository: MockUserRepository;

テストの前準備として、ユースケースとモックリポジトリを定義します。

typescriptbeforeEach(() => {
  mockRepository = new MockUserRepository();
  useCase = new CreateUserUseCase(mockRepository);
});

beforeEach フックで、各テストケースの実行前に新しいインスタンスを生成します。これにより、テスト間の影響を防ぎます。

typescripttest('正常系:新しいユーザーを作成できる', async () => {
  const input = {
    name: '山田太郎',
    email: 'yamada@example.com',
  };

  const result = await useCase.execute(input);

  expect(result.name).toBe('山田太郎');
  expect(result.email).toBe('yamada@example.com');
  expect(result.id).toBeDefined();
});

正常系のテストでは、期待通りにユーザーが作成されることを検証します。

typescripttest('異常系:重複したメールアドレスの場合エラー', async () => {
  // 既存ユーザーをセットアップ
  const existingUser = new User(
    '1',
    '既存ユーザー',
    'existing@example.com',
    new Date()
  );
  mockRepository.setUsers([existingUser]);

  const input = {
    name: '新規ユーザー',
    email: 'existing@example.com',
  };

  expect(useCase.execute(input)).rejects.toThrow(
    'このメールアドレスは既に登録されています'
  );
});

異常系のテストでは、重複チェックが正しく動作することを確認します。

typescript  test("異常系:無効なメールアドレスの場合エラー", async () => {
    const input = {
      name: "テストユーザー",
      email: "invalid-email"
    };

    expect(useCase.execute(input)).rejects.toThrow(
      "有効なメールアドレスを入力してください"
    );
  });
});

エンティティのバリデーションも、ユースケースのテストで検証できます。

以下の図は、テスト時の依存関係を示しています。

mermaidflowchart TB
  test["テストコード"] -->|使用| usecase["CreateUserUseCase"]
  usecase -->|依存| interface["IUserRepository<br/>インターフェース"]
  mock["MockUserRepository"] -->|実装| interface
  test -->|注入| mock

  impl["UserRepositoryImpl<br/>(本番用)"] -.->|実装(テスト時は不使用)| interface

  style test fill:#e1ffe1
  style mock fill:#e1ffe1
  style usecase fill:#fff4e1
  style interface fill:#e1f5ff
  style impl fill:#f0f0f0

テスト時は本番用の実装を使わず、モックを注入することで、外部依存なしでテストを実行できます。これが DI とインターフェースがもたらすテスト容易性の利点です。

具体例

Bun で実装する完全なアプリケーション

実際に Bun を使って、ユーザー管理 API を作成してみましょう。

プロジェクトのセットアップ

まず、Bun プロジェクトを初期化します。

bash# プロジェクトの作成
mkdir bun-clean-architecture
cd bun-clean-architecture

# Bunプロジェクトの初期化
bun init -y

次に、ディレクトリ構造を作成します。

bash# ディレクトリの作成
mkdir -p src/{domain/{entities,repositories},usecase/user,interface/{repositories,presenters},infrastructure/{web,di}}
mkdir -p tests/{mocks,usecase}

プロジェクト構造は以下のようになります。

gobun-clean-architecture/
├── src/
│   ├── domain/
│   │   ├── entities/
│   │   └── repositories/
│   ├── usecase/
│   │   └── user/
│   ├── interface/
│   │   ├── repositories/
│   │   └── presenters/
│   └── infrastructure/
│       ├── web/
│       └── di/
├── tests/
│   ├── mocks/
│   └── usecase/
└── package.json

エンドポイントの実装

HTTP サーバーを実装し、クリーンアーキテクチャと連携させます。

typescript// infrastructure/web/Server.ts
import { Container } from "../di/Container";
import { CreateUserUseCase } from "../../usecase/user/CreateUserUseCase";

export class Server {
  constructor(private readonly container: Container) {}

Server クラスでは、DI コンテナを受け取り、各エンドポイントでユースケースを取得します。

typescript  async start(port: number): Promise<void> {
    const server = Bun.serve({
      port,
      async fetch(req) {
        const url = new URL(req.url);

        // ルーティング
        if (url.pathname === "/users" && req.method === "POST") {
          return await this.handleCreateUser(req);
        }

        return new Response("Not Found", { status: 404 });
      }.bind(this)
    });

    console.log(`サーバーが起動しました: http://localhost:${port}`);
  }

Bun の組み込み HTTP サーバーを使用します。fetch メソッドでリクエストを処理し、パスと HTTP メソッドに応じてハンドラーを呼び出します。

typescript  private async handleCreateUser(req: Request): Promise<Response> {
    try {
      // リクエストボディの解析
      const body = await req.json();

      // ユースケースの取得
      const useCase = this.container.resolve<CreateUserUseCase>(
        "CreateUserUseCase"
      );

      // ユースケースの実行
      const result = await useCase.execute({
        name: body.name,
        email: body.email
      });

ユーザー作成のリクエストを処理します。DI コンテナからユースケースを取得し、実行します。

typescript      // レスポンスの返却
      return new Response(JSON.stringify(result), {
        status: 201,
        headers: { "Content-Type": "application/json" }
      });
    } catch (error: any) {
      return new Response(
        JSON.stringify({ error: error.message }),
        {
          status: 400,
          headers: { "Content-Type": "application/json" }
        }
      );
    }
  }
}

成功時はステータスコード 201、エラー時は 400 を返します。エラーメッセージも JSON 形式で返却します。

DI コンテナの設定

アプリケーション起動時に、依存関係を登録します。

typescript// infrastructure/di/setup.ts
import { Container } from "./Container";
import { UserRepositoryImpl } from "../../interface/repositories/UserRepositoryImpl";
import { CreateUserUseCase } from "../../usecase/user/CreateUserUseCase";

export function setupContainer(): Container {
  const container = new Container();

setupContainer 関数で、DI コンテナの初期設定を行います。

typescript// リポジトリの登録(シングルトン)
const userRepository = new UserRepositoryImpl();
container.register('UserRepository', userRepository);

リポジトリは 1 つのインスタンスを共有するため、register メソッドで登録します。

typescript  // ユースケースの登録(ファクトリー)
  container.registerFactory("CreateUserUseCase", () => {
    return new CreateUserUseCase(
      container.resolve("UserRepository")
    );
  });

  return container;
}

ユースケースはリクエストごとに新しいインスタンスを作成するため、registerFactory メソッドを使用します。ファクトリー関数内で、リポジトリを注入します。

アプリケーションのエントリーポイント

すべてを統合して、アプリケーションを起動します。

typescript// src/index.ts
import { setupContainer } from './infrastructure/di/setup';
import { Server } from './infrastructure/web/Server';

async function main() {
  // DIコンテナのセットアップ
  const container = setupContainer();

  // サーバーの起動
  const server = new Server(container);
  await server.start(3000);
}

main().catch(console.error);

main 関数で DI コンテナをセットアップし、サーバーを起動します。

アプリケーションを実行してみましょう。

bash# アプリケーションの起動
bun run src/index.ts

curl コマンドで API をテストします。

bash# ユーザー作成のリクエスト
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"name":"山田太郎","email":"yamada@example.com"}'

以下のようなレスポンスが返ってきます。

json{
  "id": "1699876543210-abc123def",
  "name": "山田太郎",
  "email": "yamada@example.com",
  "createdAt": "2024-11-13T12:34:56.789Z"
}

統合テストの実装

実際の HTTP リクエストを使った統合テストを作成します。

typescript// tests/integration/api.test.ts
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { setupContainer } from "../../src/infrastructure/di/setup";
import { Server } from "../../src/infrastructure/web/Server";

describe("User API統合テスト", () => {
  let server: Server;
  const baseUrl = "http://localhost:3001";

統合テストでは、実際にサーバーを起動してテストを行います。

typescriptbeforeAll(async () => {
  const container = setupContainer();
  server = new Server(container);
  await server.start(3001);
});

beforeAll フックで、全テストの実行前にサーバーを起動します。

typescripttest('POST /users - ユーザーを作成できる', async () => {
  const response = await fetch(`${baseUrl}/users`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: '統合テストユーザー',
      email: 'integration@example.com',
    }),
  });

  expect(response.status).toBe(201);

  const data = await response.json();
  expect(data.name).toBe('統合テストユーザー');
  expect(data.email).toBe('integration@example.com');
});

実際に HTTP リクエストを送信し、レスポンスを検証します。

typescript  test("POST /users - 無効なデータでエラー", async () => {
    const response = await fetch(`${baseUrl}/users`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        name: "",
        email: "invalid-email"
      })
    });

    expect(response.status).toBe(400);

    const data = await response.json();
    expect(data.error).toBeDefined();
  });
});

異常系のテストでは、バリデーションエラーが正しく返されることを確認します。

以下の図は、リクエストからレスポンスまでの流れを示しています。

mermaidsequenceDiagram
  participant Client as クライアント
  participant Server as HTTPサーバー
  participant UseCase as CreateUserUseCase
  participant Entity as Userエンティティ
  participant Repo as UserRepository

  Client->>Server: POST /users
  Server->>Server: DIコンテナから<br/>UseCaseを取得
  Server->>UseCase: execute(input)
  UseCase->>Repo: findByEmail(email)
  Repo-->>UseCase: null(重複なし)
  UseCase->>Entity: new User(...)
  Entity->>Entity: validate()
  Entity-->>UseCase: Userインスタンス
  UseCase->>Repo: save(user)
  Repo-->>UseCase: 完了
  UseCase-->>Server: CreateUserOutput
  Server-->>Client: 201 Created + JSON

この図から、各層が疎結合に保たれながら、適切に連携していることが分かります。

データベース実装への切り替え

メモリ内実装から、実際のデータベースへの切り替えも容易です。

typescript// interface/repositories/UserRepositoryPostgres.ts
import { User } from "../../domain/entities/User";
import { IUserRepository } from "../../domain/repositories/IUserRepository";
import postgres from "postgres";

export class UserRepositoryPostgres implements IUserRepository {
  private sql: ReturnType<typeof postgres>;

  constructor(connectionString: string) {
    this.sql = postgres(connectionString);
  }

PostgreSQL 用のリポジトリ実装を作成します。コンストラクタで接続文字列を受け取ります。

typescript  async findById(id: string): Promise<User | null> {
    const rows = await this.sql`
      SELECT id, name, email, created_at
      FROM users
      WHERE id = ${id}
    `;

    if (rows.length === 0) return null;

    return this.mapToEntity(rows[0]);
  }

SQL クエリを実行し、結果を User エンティティに変換します。

typescript  async findByEmail(email: string): Promise<User | null> {
    const rows = await this.sql`
      SELECT id, name, email, created_at
      FROM users
      WHERE email = ${email}
    `;

    if (rows.length === 0) return null;

    return this.mapToEntity(rows[0]);
  }

メールアドレスでの検索も同様に実装します。

typescript  async save(user: User): Promise<void> {
    await this.sql`
      INSERT INTO users (id, name, email, created_at)
      VALUES (${user.id}, ${user.name}, ${user.email}, ${user.createdAt})
      ON CONFLICT (id) DO UPDATE
      SET name = ${user.name}, email = ${user.email}
    `;
  }

  async delete(id: string): Promise<void> {
    await this.sql`DELETE FROM users WHERE id = ${id}`;
  }

保存と削除のメソッドを実装します。save メソッドは UPSERT(存在すれば更新、なければ挿入)として動作します。

typescript  private mapToEntity(row: any): User {
    return new User(
      row.id,
      row.name,
      row.email,
      new Date(row.created_at)
    );
  }
}

データベースの行を User エンティティに変換するヘルパーメソッドです。

DI コンテナの設定を変更するだけで、データベース実装に切り替えられます。

typescript// infrastructure/di/setup.ts(変更後)
export function setupContainer(): Container {
  const container = new Container();

  // PostgreSQL実装に切り替え
  const userRepository = new UserRepositoryPostgres(
    process.env.DATABASE_URL || 'postgres://localhost/myapp'
  );
  container.register('UserRepository', userRepository);

  // 以下は変更なし
  container.registerFactory('CreateUserUseCase', () => {
    return new CreateUserUseCase(
      container.resolve('UserRepository')
    );
  });

  return container;
}

リポジトリの実装を変更しても、ユースケースやエンティティには一切変更が不要です。これがクリーンアーキテクチャの依存性逆転原則の力です。

#変更箇所変更内容影響範囲
1DI コンテナ設定リポジトリ実装クラスの差し替え1 ファイルのみ
2リポジトリ実装新規クラスの追加新規ファイル
3ユースケース変更なしなし
4エンティティ変更なしなし

データベースの変更が局所的に収まり、ビジネスロジックに影響を与えません。

まとめ

Bun でクリーンアーキテクチャを実装することで、以下のメリットが得られます。

実現できたこと

ドメイン分離による保守性向上

4 層アーキテクチャにより、ビジネスロジックが外部技術から独立しました。データベースやフレームワークの変更が、ドメイン層に影響を与えません。各層の責務が明確になり、コードの理解と変更が容易になります。

依存性注入によるテスト容易性

インターフェースと依存性注入を活用することで、単体テストが簡単になりました。モックを注入するだけで、外部依存なしでテストを実行できます。Bun の高速なテストランナーと組み合わせることで、快適なテスト駆動開発が可能です。

拡張性の高い設計

新しい機能の追加や既存機能の変更が、影響範囲を限定して行えます。例えば、新しいユースケースの追加は、既存のコードに影響を与えずに実装できます。リポジトリの実装を差し替えるだけで、異なるデータソースに対応できます。

設計のポイント

クリーンアーキテクチャを成功させるためのポイントをまとめます。

#ポイント説明効果
1依存の方向性外側から内側へのみ依存変更の影響範囲を限定
2インターフェース活用抽象に依存するテスト容易性の向上
3エンティティの独立性ビジネスルールを集約ドメイン知識の明確化
4DI コンテナ依存関係の一元管理結合度の低減

Bun ならではの利点

Bun の特性がクリーンアーキテクチャと非常に相性が良いことも、実装を通じて実感できました。

  • 高速なテスト実行: bun testによる素早いフィードバックループ
  • TypeScript ネイティブ: 型安全性を保ちながらの開発
  • シンプルな HTTP サーバー: 軽量なインフラ層の実装
  • 高速な起動時間: 開発サイクルの短縮

次のステップ

この記事で学んだ設計パターンを応用することで、さらに複雑なアプリケーションも構築できます。以下のような拡張が考えられますね。

  • 認証・認可の追加: ミドルウェアパターンでの実装
  • イベント駆動アーキテクチャ: ドメインイベントの導入
  • CQRS: コマンドとクエリの分離
  • 複数のユースケース: ユーザー更新、削除、一覧取得など

クリーンアーキテクチャは初めは複雑に感じるかもしれませんが、一度慣れると保守性と拡張性の高いシステムを構築できます。Bun の高速性と相まって、快適な開発体験を得られるでしょう。

ぜひ、この記事を参考に、ご自身のプロジェクトでクリーンアーキテクチャを導入してみてください。

関連リンク