ストーリー
演習の概要
タスク管理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 | ドメインモデルの設計 | [ ] |
| 2 | Portの定義 | [ ] |
| 3 | Use Case実装 | [ ] |
| 4 | InMemory Adapter実装 | [ ] |
| 5 | テストの作成 | [ ] |
| 6 | HTTP Adapter実装 | [ ] |
チェックリスト
- ドメインモデルが外部依存を持たない
- PortがISPに準拠している(1 Use Case = 1 Port)
- Use Caseがインターフェースにのみ依存している
- DB接続なしでテストが実行できる
- HTTP Adapterが変換のみを行い、ビジネスロジックを含まない
次のステップへ
演習お疲れさまでした。次はStep 2のチェックポイントクイズに挑戦しましょう。
推定所要時間: 90分