EXERCISE 90分

ストーリー

高橋アーキテクト
理論は十分だ。手を動かそう
高橋アーキテクト
チームに”タスク管理API”の開発依頼が来ている。ヘキサゴナルアーキテクチャで設計・実装してくれ。外部依存なしでテストできる構造にすること。これが条件だ

演習の概要

タスク管理APIをヘキサゴナルアーキテクチャで設計・実装してください。

項目内容
テーマタスク管理API
アーキテクチャヘキサゴナル(Ports & Adapters)
言語TypeScript
ミッション数6
推定時間90分

Mission 1: ドメインモデルを設計しよう(15分)

以下の要件を満たすTaskエンティティとValue Objectを設計してください。

要件:

  • タスクにはタイトル(必須、100文字以内)、説明(任意)、ステータス(TODO/IN_PROGRESS/DONE)がある
  • タスクはTODO → IN_PROGRESS → DONEの順にのみ遷移する
  • 完了済みタスクは再びTODOに戻せない
解答例
// domain/value-objects/TaskId.ts
class TaskId {
  private constructor(private readonly _value: string) {}
  static generate(): TaskId { return new TaskId(crypto.randomUUID()); }
  static fromString(value: string): TaskId { return new TaskId(value); }
  get value(): string { return this._value; }
  equals(other: TaskId): boolean { return this._value === other._value; }
}

// domain/value-objects/TaskTitle.ts
class TaskTitle {
  private constructor(private readonly _value: string) {}
  static of(value: string): TaskTitle {
    if (!value || value.trim().length === 0) throw new Error('タイトルは必須です');
    if (value.length > 100) throw new Error('タイトルは100文字以内です');
    return new TaskTitle(value.trim());
  }
  get value(): string { return this._value; }
}

// domain/value-objects/TaskStatus.ts
enum TaskStatus { TODO = 'TODO', IN_PROGRESS = 'IN_PROGRESS', DONE = 'DONE' }

// domain/entities/Task.ts
class Task {
  private constructor(
    private readonly _id: TaskId,
    private _title: TaskTitle,
    private _description: string | null,
    private _status: TaskStatus,
    private readonly _createdAt: Date
  ) {}

  static create(title: string, description?: string): Task {
    return new Task(
      TaskId.generate(),
      TaskTitle.of(title),
      description ?? null,
      TaskStatus.TODO,
      new Date()
    );
  }

  get id(): TaskId { return this._id; }
  get title(): TaskTitle { return this._title; }
  get status(): TaskStatus { return this._status; }

  startProgress(): void {
    if (this._status !== TaskStatus.TODO) {
      throw new Error('進行中にできるのはTODOのタスクのみです');
    }
    this._status = TaskStatus.IN_PROGRESS;
  }

  complete(): void {
    if (this._status !== TaskStatus.IN_PROGRESS) {
      throw new Error('完了にできるのは進行中のタスクのみです');
    }
    this._status = TaskStatus.DONE;
  }

  toPersistence() {
    return {
      id: this._id.value,
      title: this._title.value,
      description: this._description,
      status: this._status,
      createdAt: this._createdAt,
    };
  }

  static fromPersistence(data: any): Task {
    return new Task(
      TaskId.fromString(data.id),
      TaskTitle.of(data.title),
      data.description,
      data.status as TaskStatus,
      data.createdAt
    );
  }
}

Mission 2: Portを定義しよう(10分)

Driving Port(Use Case)とDriven Port(Repository)を定義してください。

要件:

  • タスク作成、タスク一覧取得、ステータス変更の3つのUse Case
  • Repository: findById, save, findAll
解答例
// domain/ports/in/CreateTaskUseCase.ts
interface CreateTaskUseCase {
  execute(command: CreateTaskCommand): Promise<TaskId>;
}
interface CreateTaskCommand {
  title: string;
  description?: string;
}

// domain/ports/in/ListTasksUseCase.ts
interface ListTasksUseCase {
  execute(): Promise<TaskDto[]>;
}
interface TaskDto {
  id: string;
  title: string;
  description: string | null;
  status: string;
  createdAt: string;
}

// domain/ports/in/ChangeTaskStatusUseCase.ts
interface ChangeTaskStatusUseCase {
  execute(command: ChangeTaskStatusCommand): Promise<void>;
}
interface ChangeTaskStatusCommand {
  taskId: string;
  action: 'start' | 'complete';
}

// domain/ports/out/TaskRepository.ts
interface TaskRepository {
  findById(id: TaskId): Promise<Task | null>;
  save(task: Task): Promise<void>;
  findAll(): Promise<Task[]>;
}

Mission 3: Use Case実装を書こう(15分)

Driving Portの実装(Use Case)を書いてください。

解答例
// application/CreateTaskUseCaseImpl.ts
class CreateTaskUseCaseImpl implements CreateTaskUseCase {
  constructor(private taskRepo: TaskRepository) {}

  async execute(command: CreateTaskCommand): Promise<TaskId> {
    const task = Task.create(command.title, command.description);
    await this.taskRepo.save(task);
    return task.id;
  }
}

// application/ListTasksUseCaseImpl.ts
class ListTasksUseCaseImpl implements ListTasksUseCase {
  constructor(private taskRepo: TaskRepository) {}

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

// application/ChangeTaskStatusUseCaseImpl.ts
class ChangeTaskStatusUseCaseImpl implements ChangeTaskStatusUseCase {
  constructor(private taskRepo: TaskRepository) {}

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

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

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

Mission 4: InMemory Adapterを実装しよう(10分)

テスト用のInMemoryTaskRepositoryを実装してください。

解答例
// adapters/out/persistence/InMemoryTaskRepository.ts
class InMemoryTaskRepository implements TaskRepository {
  private store: Map<string, Task> = new Map();

  async findById(id: TaskId): Promise<Task | null> {
    return this.store.get(id.value) ?? null;
  }

  async save(task: Task): Promise<void> {
    this.store.set(task.id.value, task);
  }

  async findAll(): Promise<Task[]> {
    return [...this.store.values()];
  }

  clear(): void { this.store.clear(); }
}

Mission 5: テストを書こう(20分)

InMemory Adapterを使って、Use Caseのテストを書いてください。DB接続なしで実行できることを確認してください。

解答例
describe('CreateTaskUseCase', () => {
  let repo: InMemoryTaskRepository;
  let useCase: CreateTaskUseCase;

  beforeEach(() => {
    repo = new InMemoryTaskRepository();
    useCase = new CreateTaskUseCaseImpl(repo);
  });

  it('タスクを作成できる', async () => {
    const taskId = await useCase.execute({
      title: 'テストタスク',
      description: '説明文',
    });
    expect(taskId).toBeDefined();
    const saved = await repo.findById(taskId);
    expect(saved).not.toBeNull();
    expect(saved!.title.value).toBe('テストタスク');
  });

  it('タイトルが空の場合はエラー', async () => {
    await expect(useCase.execute({ title: '' }))
      .rejects.toThrow('タイトルは必須です');
  });
});

describe('ChangeTaskStatusUseCase', () => {
  let repo: InMemoryTaskRepository;
  let useCase: ChangeTaskStatusUseCase;

  beforeEach(() => {
    repo = new InMemoryTaskRepository();
    useCase = new ChangeTaskStatusUseCaseImpl(repo);
  });

  it('TODOからIN_PROGRESSに遷移できる', async () => {
    const task = Task.create('タスク');
    await repo.save(task);

    await useCase.execute({ taskId: task.id.value, action: 'start' });
    const updated = await repo.findById(task.id);
    expect(updated!.status).toBe(TaskStatus.IN_PROGRESS);
  });

  it('DONEからstartはエラー', async () => {
    const task = Task.create('タスク');
    task.startProgress();
    task.complete();
    await repo.save(task);

    await expect(
      useCase.execute({ taskId: task.id.value, action: 'start' })
    ).rejects.toThrow();
  });
});

Mission 6: HTTP Adapterを実装しよう(20分)

Express用のDriving Adapterを実装してください。

解答例
// adapters/in/http/TaskController.ts
class TaskController {
  constructor(
    private createTask: CreateTaskUseCase,
    private listTasks: ListTasksUseCase,
    private changeStatus: ChangeTaskStatusUseCase
  ) {}

  async create(req: Request, res: Response): Promise<void> {
    try {
      const command: CreateTaskCommand = {
        title: req.body.title,
        description: req.body.description,
      };
      const taskId = await this.createTask.execute(command);
      res.status(201).json({ taskId: taskId.value });
    } catch (error) {
      res.status(400).json({ error: (error as Error).message });
    }
  }

  async list(_req: Request, res: Response): Promise<void> {
    const tasks = await this.listTasks.execute();
    res.json({ tasks });
  }

  async updateStatus(req: Request, res: Response): Promise<void> {
    try {
      const command: ChangeTaskStatusCommand = {
        taskId: req.params.id,
        action: req.body.action,
      };
      await this.changeStatus.execute(command);
      res.status(204).send();
    } catch (error) {
      res.status(400).json({ error: (error as Error).message });
    }
  }
}

// adapters/in/http/router.ts
const router = express.Router();
// DIコンテナからUse Caseを取得してControllerに注入
const controller = new TaskController(createTask, listTasks, changeStatus);
router.post('/tasks', (req, res) => controller.create(req, res));
router.get('/tasks', (req, res) => controller.list(req, res));
router.patch('/tasks/:id/status', (req, res) => controller.updateStatus(req, res));

達成度チェック

Mission内容完了
1ドメインモデルの設計[ ]
2Portの定義[ ]
3Use Case実装[ ]
4InMemory Adapter実装[ ]
5テストの作成[ ]
6HTTP Adapter実装[ ]

チェックリスト

  • ドメインモデルが外部依存を持たない
  • PortがISPに準拠している(1 Use Case = 1 Port)
  • Use Caseがインターフェースにのみ依存している
  • DB接続なしでテストが実行できる
  • HTTP Adapterが変換のみを行い、ビジネスロジックを含まない

次のステップへ

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


推定所要時間: 90分