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;
}
リポジトリの実装を変更しても、ユースケースやエンティティには一切変更が不要です。これがクリーンアーキテクチャの依存性逆転原則の力です。
| # | 変更箇所 | 変更内容 | 影響範囲 |
|---|---|---|---|
| 1 | DI コンテナ設定 | リポジトリ実装クラスの差し替え | 1 ファイルのみ |
| 2 | リポジトリ実装 | 新規クラスの追加 | 新規ファイル |
| 3 | ユースケース | 変更なし | なし |
| 4 | エンティティ | 変更なし | なし |
データベースの変更が局所的に収まり、ビジネスロジックに影響を与えません。
まとめ
Bun でクリーンアーキテクチャを実装することで、以下のメリットが得られます。
実現できたこと
ドメイン分離による保守性向上
4 層アーキテクチャにより、ビジネスロジックが外部技術から独立しました。データベースやフレームワークの変更が、ドメイン層に影響を与えません。各層の責務が明確になり、コードの理解と変更が容易になります。
依存性注入によるテスト容易性
インターフェースと依存性注入を活用することで、単体テストが簡単になりました。モックを注入するだけで、外部依存なしでテストを実行できます。Bun の高速なテストランナーと組み合わせることで、快適なテスト駆動開発が可能です。
拡張性の高い設計
新しい機能の追加や既存機能の変更が、影響範囲を限定して行えます。例えば、新しいユースケースの追加は、既存のコードに影響を与えずに実装できます。リポジトリの実装を差し替えるだけで、異なるデータソースに対応できます。
設計のポイント
クリーンアーキテクチャを成功させるためのポイントをまとめます。
| # | ポイント | 説明 | 効果 |
|---|---|---|---|
| 1 | 依存の方向性 | 外側から内側へのみ依存 | 変更の影響範囲を限定 |
| 2 | インターフェース活用 | 抽象に依存する | テスト容易性の向上 |
| 3 | エンティティの独立性 | ビジネスルールを集約 | ドメイン知識の明確化 |
| 4 | DI コンテナ | 依存関係の一元管理 | 結合度の低減 |
Bun ならではの利点
Bun の特性がクリーンアーキテクチャと非常に相性が良いことも、実装を通じて実感できました。
- 高速なテスト実行:
bun testによる素早いフィードバックループ - TypeScript ネイティブ: 型安全性を保ちながらの開発
- シンプルな HTTP サーバー: 軽量なインフラ層の実装
- 高速な起動時間: 開発サイクルの短縮
次のステップ
この記事で学んだ設計パターンを応用することで、さらに複雑なアプリケーションも構築できます。以下のような拡張が考えられますね。
- 認証・認可の追加: ミドルウェアパターンでの実装
- イベント駆動アーキテクチャ: ドメインイベントの導入
- CQRS: コマンドとクエリの分離
- 複数のユースケース: ユーザー更新、削除、一覧取得など
クリーンアーキテクチャは初めは複雑に感じるかもしれませんが、一度慣れると保守性と拡張性の高いシステムを構築できます。Bun の高速性と相まって、快適な開発体験を得られるでしょう。
ぜひ、この記事を参考に、ご自身のプロジェクトでクリーンアーキテクチャを導入してみてください。
関連リンク
articleBun でクリーンアーキテクチャ:ドメイン分離・DI・テスト容易性の設計指針
articleNode.js と Deno と Bun を実測比較:コールドスタート/IO/HTTP/互換性レポート
articleBun コマンド チートシート:bun install/run/x/test/build 一括早見表
articleBun のインストール完全ガイド:macOS/Linux/Windows(WSL)対応の最短手順
articleBun とは?Node.js・Deno と何が違うのかを 3 分で理解【2025 年最新版】
articleSvelteKit アダプタ比較:Node/Vercel/Cloudflare/Netlify の速度と制約
articleClaude Code リファクタ提案 vs 人手レビュー:可読性・循環的複雑度で検証
articleBun でクリーンアーキテクチャ:ドメイン分離・DI・テスト容易性の設計指針
articleStorybook 代替ツール比較:Ladle/Histoire/Pattern Lab と何が違う?
articleAnsible Inventory 初期構築:静的/動的の基本とベストプラクティス
articleSolidJS で無限ループが止まらない!createEffect/onCleanup の正しい書き方
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来