テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その6

はじめに Part5でUIまで実装が終わった。機能として動くものはできた。テストも15件、全パス。 ここで一度立ち止まって、なぜこの設計になったのかを振り返る。 正直に言う。最初の要件はこうだった。 「クリーンアーキテクチャっぽく、テスタブルにしたい」 それだけだった。クリーンアーキテクチャを理解して設計したわけじゃない。 結果的にクリーンアーキテクチャを遂行したのは生成AIだ。 そしてPart3以降、自分はほとんど手を動かさなくなった。 設計ドキュメントをAIに渡したら、実装サイクルがほぼ自動で回るようになったからだ。 途中でこう思った。 「俺いる必要なくね?」 この記事はその問いに向き合いながら、設計を改めて言語化したものだ。 このシリーズ自体がTDDだった 書き終えてから気づいたことがある。 このシリーズのサイクルはこうだった。 AIに実装させる ↓ 動かす・読む・会話する(認識のズレを検出) ↓ ズレを言語化してAIにフィードバック ↓ 納得したら記事にする ソフトウェアのTDDは「Red → Green → Refactor」だけど、 自分がやっていたのはこれだ。 Red → 認識がズレていると感じる Green → 会話して納得する Refactor → 記事として言語化する 人間がテストケースになっていた。 TDDが「仕様をテストで表現する」なら、自分がやっていたのは「理解をフィードバックループで検証する」だ。構造は同じだと思う。 誤っていた認識たち 振り返ると、理解がズレていた箇所がいくつかあった。 「PolicyはVOと1-1になる」と思っていた 最初、ReadingStatusPolicyがReadingStatusに対応しているのを見て、PolicyはVOと対になるものだと思っていた。 違う。今回たまたま1-1になっているだけだ。 Policyの本質は複数のEntityやVOをまたいだ条件判定だ。たとえば「同じ本に同じユーザーが2回レビューできない」というルールは、Book・User・Review[]をまたぐ。これをPolicyに出す。 VOに閉じるルールならVOに書けばいい。Entityをまたぐ判定が必要になったときにPolicyの出番だ。 「Domainは外部を知らない」という捉え方が逆だった 「DomainはDBを知らない」という言い方をよくするが、正確には逆だ。 外部がDomainを知っている。 向きの問題だ。Domainが何かを避けているのではなく、依存の矢印がすべてDomainに向かって刺さっている。 Route Handler ↓ Service ↓ Repository interface ↓ Domain(Entity / ValueObject / Policy) この向きがわかって初めて、Serviceの役割も見えた。 Serviceが何をするのかわかっていなかった 依存の向きが腑に落ちるまで、Serviceが何者なのかずっと曖昧だった。 答えはシンプルだった。フローだけ持つ接着剤。 async startReading(id: string): Promise<Book> { const book = await this.repo.findById(id); // 取得 if (!book) throw new NotFoundError(...); book.changeStatus(ReadingStatus.Reading); // Entityのルールに従う await this.repo.save(book); // 保存 return book; } ServiceはDomainのルールを自分で判定しない。 canTransitionを自分で呼ばない。book.changeStatus()に委ねるだけだ。 判定はEntity・VO・Policyが持っている。Serviceはその結果を使ってフローを組む。 ...

March 5, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その4

はじめに Part1 でValueObject・Policy・Entityを作り、Part2 でServiceをDI+Mockテストで固めた。 Part3 でPrismaを繋ぎ、GET /api/books と POST /api/books を動かした。 Part4では残りのエンドポイントを実装する。やることは3つ。 カスタム例外クラスの導入 PATCH /api/books/:id/start と PATCH /api/books/:id/complete の実装 DELETE /api/books/:id の実装 そして「エラー種別ごとにHTTPステータスを整理する」という設計判断を掘り下げる。 また、Part3でPrisma v7特有のセットアップをしたが、v6以前と何が変わったのかをここで整理しておく。 0. Prisma v7で何が変わったか Part3でPrismaをセットアップしたとき、v6以前と比べていくつか「見慣れない書き方」が必要だった。 v7は破壊的変更が多く、ネット上のv6時代の記事を参考にすると詰まる箇所がある。 ここで整理しておく。 参考: Upgrade to Prisma ORM 7 | Prisma Documentation generator の変更 // ❌ v6以前 generator client { provider = "prisma-client-js" } // ✅ v7 generator client { provider = "prisma-client" output = "../src/generated/prisma" } v7では prisma-client-js が廃止され prisma-client に変わった。 Rustベースのエンジンを廃止しTypeScriptネイティブになったことに伴う変更だ。 また output が必須になり、node_modules への自動生成はなくなった。 ...

March 5, 2026 · 6 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その3

はじめに Part1 でValueObject・Policy・Entityを作り、Part2 でServiceをDI+Mockテストで固めた。 累計13テスト、全パスの状態だ。 Part3ではいよいよDBを繋ぐ。やることは3つ。 Prismaセットアップ+スキーマ定義 PrismaBookRepository 実装(ドメインオブジェクトへの変換) Route HandlerでDIを組み立てる そして最後に「テストを書かない層を意図的に決める」という話をする。 1. Prismaセットアップ npm install prisma @prisma/client npx prisma init --datasource-provider sqlite prisma/schema.prisma に Bookモデルを定義する。 generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Book { id String @id title String isbn String status String rating Int? } User と Review はまだDBに持たない。Book の CRUD が動けば Part3 のゴール。 なぜ status を String で持つのか Prismaは SQLiteで enum をネイティブサポートしていない。 そのため status String で持ち、取り出し時に as ReadingStatus でキャストする。 ...

March 4, 2026 · 4 min