EXERCISE 60分

ストーリー

高橋アーキテクト
Step 2でタスク管理APIを作ったね。今度はそれをクリーンアーキテクチャの4層構造で再構築してみよう
高橋アーキテクト
同じ機能を違うアーキテクチャで?
高橋アーキテクト
そうだ。同じものを違う構造で作ることで、両者の違いが体感できる。今回はUse Caseの入出力を厳密に定義し、DIとComposition Rootも実装すること

演習の概要

Step 2で作成したタスク管理APIを、クリーンアーキテクチャの4層構造で再設計してください。

項目内容
テーマタスク管理APIの再構築
アーキテクチャクリーンアーキテクチャ(4層)
言語TypeScript
ミッション数5
推定時間60分

Mission 1: ディレクトリ構造を設計しよう(10分)

クリーンアーキテクチャの4層に対応したディレクトリ構造を設計してください。

解答例
src/
├── entities/                    # Layer 1: Enterprise Business Rules
│   ├── Task.ts
│   ├── TaskId.ts
│   ├── TaskTitle.ts
│   └── TaskStatus.ts
├── use-cases/                   # Layer 2: Application Business Rules
│   ├── CreateTaskUseCase.ts
│   ├── ListTasksUseCase.ts
│   ├── ChangeTaskStatusUseCase.ts
│   ├── dto/
│   │   ├── CreateTaskInput.ts
│   │   ├── CreateTaskOutput.ts
│   │   ├── ListTasksOutput.ts
│   │   ├── ChangeTaskStatusInput.ts
│   │   └── TaskDto.ts
│   └── ports/
│       └── TaskRepository.ts    # Output Port (interface)
├── adapters/                    # Layer 3: Interface Adapters
│   ├── controllers/
│   │   └── TaskController.ts
│   └── gateways/
│       ├── PrismaTaskRepository.ts
│       └── InMemoryTaskRepository.ts
├── frameworks/                  # Layer 4: Frameworks & Drivers
│   ├── express/
│   │   └── router.ts
│   └── prisma/
│       └── schema.prisma
├── composition-root.ts          # DI設定
└── main.ts                      # エントリーポイント

Mission 2: Use CaseのInput/Outputを定義しよう(10分)

各Use CaseのInput型とOutput型を定義してください。

解答例
// use-cases/dto/CreateTaskInput.ts
interface CreateTaskInput {
  readonly title: string;
  readonly description?: string;
}

// use-cases/dto/CreateTaskOutput.ts
interface CreateTaskOutput {
  readonly taskId: string;
  readonly createdAt: string;
}

// use-cases/dto/ChangeTaskStatusInput.ts
interface ChangeTaskStatusInput {
  readonly taskId: string;
  readonly action: 'start' | 'complete';
}

// use-cases/dto/TaskDto.ts
interface TaskDto {
  readonly id: string;
  readonly title: string;
  readonly description: string | null;
  readonly status: string;
  readonly createdAt: string;
}

// use-cases/dto/ListTasksOutput.ts
interface ListTasksOutput {
  readonly tasks: TaskDto[];
  readonly totalCount: number;
}

Mission 3: Use Caseを実装しよう(15分)

Input/Outputの型を使ってUse Caseを実装してください。

解答例
// use-cases/CreateTaskUseCase.ts
class CreateTaskUseCase {
  constructor(private taskRepo: TaskRepository) {}

  async execute(input: CreateTaskInput): Promise<CreateTaskOutput> {
    const task = Task.create(input.title, input.description);
    await this.taskRepo.save(task);
    return {
      taskId: task.id.value,
      createdAt: task.toPersistence().createdAt.toISOString(),
    };
  }
}

// use-cases/ListTasksUseCase.ts
class ListTasksUseCase {
  constructor(private taskRepo: TaskRepository) {}

  async execute(): Promise<ListTasksOutput> {
    const tasks = await this.taskRepo.findAll();
    return {
      tasks: tasks.map(task => ({
        id: task.id.value,
        title: task.title.value,
        description: task.toPersistence().description,
        status: task.status,
        createdAt: task.toPersistence().createdAt.toISOString(),
      })),
      totalCount: tasks.length,
    };
  }
}

// use-cases/ChangeTaskStatusUseCase.ts
class ChangeTaskStatusUseCase {
  constructor(private taskRepo: TaskRepository) {}

  async execute(input: ChangeTaskStatusInput): Promise<void> {
    const taskId = TaskId.fromString(input.taskId);
    const task = await this.taskRepo.findById(taskId);
    if (!task) throw new Error('タスクが見つかりません');

    if (input.action === 'start') {
      task.startProgress();
    } else if (input.action === 'complete') {
      task.complete();
    }

    await this.taskRepo.save(task);
  }
}

Mission 4: Composition Rootを実装しよう(10分)

本番用とテスト用のComposition Rootを実装してください。

解答例
// composition-root.ts(本番用)
function createProductionContainer() {
  const prisma = new PrismaClient();
  const taskRepo = new PrismaTaskRepository(prisma);

  const createTask = new CreateTaskUseCase(taskRepo);
  const listTasks = new ListTasksUseCase(taskRepo);
  const changeStatus = new ChangeTaskStatusUseCase(taskRepo);

  const taskController = new TaskController(createTask, listTasks, changeStatus);

  return { taskController, prisma };
}

// test/helpers/createTestContainer.ts(テスト用)
function createTestContainer() {
  const taskRepo = new InMemoryTaskRepository();

  const createTask = new CreateTaskUseCase(taskRepo);
  const listTasks = new ListTasksUseCase(taskRepo);
  const changeStatus = new ChangeTaskStatusUseCase(taskRepo);

  return {
    createTask,
    listTasks,
    changeStatus,
    taskRepo, // テストで状態を確認するために公開
  };
}

Mission 5: 各層のテストを書こう(15分)

Entity(Layer 1)とUse Case(Layer 2)のテストを書いてください。

解答例
// Layer 1: Entityのテスト
describe('Task', () => {
  it('タスクを作成できる', () => {
    const task = Task.create('テストタスク', '説明文');
    expect(task.title.value).toBe('テストタスク');
    expect(task.status).toBe(TaskStatus.TODO);
  });

  it('タイトルが100文字を超えるとエラー', () => {
    const longTitle = 'a'.repeat(101);
    expect(() => Task.create(longTitle)).toThrow('100文字以内');
  });

  it('TODO→IN_PROGRESS→DONEの順にのみ遷移できる', () => {
    const task = Task.create('タスク');

    expect(() => task.complete()).toThrow(); // TODO→DONEは不可

    task.startProgress();
    expect(task.status).toBe(TaskStatus.IN_PROGRESS);

    task.complete();
    expect(task.status).toBe(TaskStatus.DONE);
  });
});

// Layer 2: Use Caseのテスト
describe('CreateTaskUseCase', () => {
  it('タスクを作成して保存する', async () => {
    const container = createTestContainer();
    const output = await container.createTask.execute({
      title: '新しいタスク',
      description: '詳細',
    });

    expect(output.taskId).toBeDefined();
    expect(output.createdAt).toBeDefined();

    // Repositoryに保存されていることを確認
    const tasks = await container.taskRepo.findAll();
    expect(tasks.length).toBe(1);
  });
});

describe('ChangeTaskStatusUseCase', () => {
  it('タスクのステータスを変更できる', async () => {
    const container = createTestContainer();
    const output = await container.createTask.execute({ title: 'タスク' });

    await container.changeStatus.execute({
      taskId: output.taskId,
      action: 'start',
    });

    const task = await container.taskRepo.findById(
      TaskId.fromString(output.taskId)
    );
    expect(task!.status).toBe(TaskStatus.IN_PROGRESS);
  });

  it('存在しないタスクはエラー', async () => {
    const container = createTestContainer();
    await expect(
      container.changeStatus.execute({
        taskId: 'nonexistent',
        action: 'start',
      })
    ).rejects.toThrow('タスクが見つかりません');
  });
});

達成度チェック

Mission内容完了
1ディレクトリ構造の設計[ ]
2Input/Outputの定義[ ]
3Use Caseの実装[ ]
4Composition Rootの実装[ ]
5各層のテスト[ ]

チェックリスト

  • 4層構造のディレクトリが明確に分かれている
  • Use CaseがInput/Output型を使っている
  • Composition Rootで全ての依存が組み立てられている
  • Entity層のテストが外部依存なしで実行できる
  • Use Case層のテストがInMemory Adapterで実行できる

次のステップへ

演習お疲れさまでした。次はStep 3のチェックポイントクイズに挑戦しましょう。


推定所要時間: 60分