はじめに

Part5でUIまで実装が終わった。機能として動くものはできた。テストも15件、全パス。

ここで一度立ち止まって、なぜこの設計になったのかを振り返る。

正直に言う。最初の要件はこうだった。

「クリーンアーキテクチャっぽく、テスタブルにしたい」

それだけだった。クリーンアーキテクチャを理解して設計したわけじゃない。 結果的にクリーンアーキテクチャを遂行したのは生成AIだ。

そしてPart3以降、自分はほとんど手を動かさなくなった。 設計ドキュメントをAIに渡したら、実装サイクルがほぼ自動で回るようになったからだ。

途中でこう思った。

「俺いる必要なくね?」

この記事はその問いに向き合いながら、設計を改めて言語化したものだ。


このシリーズ自体がTDDだった

書き終えてから気づいたことがある。

このシリーズのサイクルはこうだった。

AIに実装させる
  ↓
動かす・読む・会話する(認識のズレを検出)
  ↓
ズレを言語化してAIにフィードバック
  ↓
納得したら記事にする

ソフトウェアのTDDは「Red → Green → Refactor」だけど、 自分がやっていたのはこれだ。

  • Red → 認識がズレていると感じる
  • Green → 会話して納得する
  • Refactor → 記事として言語化する

人間がテストケースになっていた。

TDDが「仕様をテストで表現する」なら、自分がやっていたのは「理解をフィードバックループで検証する」だ。構造は同じだと思う。


誤っていた認識たち

振り返ると、理解がズレていた箇所がいくつかあった。

「PolicyはVOと1-1になる」と思っていた

最初、ReadingStatusPolicyReadingStatusに対応しているのを見て、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はその結果を使ってフローを組む。

依存の向きがわかって、はじめてServiceの「フローだけ持つ」という役割が見えた。この順番で理解する必要があった。


設計の全体像

改めて整理する。

ValueObject(VO)

ルール付きの値。型の拡張。

export class ISBN {
  constructor(public readonly value: string) {
    if (!value.match(/^\d{13}$/)) {
      throw new Error("ISBNは13桁の数字");
    }
  }
}

ISBN型のインスタンスが存在する時点で、13桁の数字であることが保証される。

Policy

条件判定だけ。副作用なし、booleanを返すだけ。

static canTransition(from: ReadingStatus, to: ReadingStatus): boolean {
  const rules = {
    [ReadingStatus.Unread]: [ReadingStatus.Reading],
    [ReadingStatus.Reading]: [ReadingStatus.Completed],
    [ReadingStatus.Completed]: [ReadingStatus.Reading],
  };
  return rules[from].includes(to);
}

Entity

複数のVOが絡むルールを持つ構造体。Policyを呼んで状態遷移を制御する。

changeStatus(to: ReadingStatus): void {
  if (!ReadingStatusPolicy.canTransition(this.status, to)) {
    throw new Error(`${this.status}から${to}への変更は不可`);
  }
  this.status = to;
}

Service

ユースケースのフロー。ルール判定はDomainに委ねる。

Repository

DBとの橋渡し。Domainはこの存在を知らない。


なぜテストが書きやすかったか

DomainはDBもHTTPも知らないので、インスタンスを作るだけでテストできる。

test("積読から直接読了はエラー", async () => {
  const service = new BookShelfService(createMockRepo());
  await service.addBook("1", "Clean Code", "9784048860000");

  await expect(service.completeReading("1")).rejects.toThrow(
    "UnreadからCompletedへの変更は不可",
  );
});

MockはMapベースのインメモリ実装をテストファイル内に書いただけ。 Prismaもサーバーも起動していない。

依存の向きを守った結果としてテストが書きやすくなった。 テストのために設計したのではなく、設計が正しいからテストが書けた。


クリーンアーキテクチャの簡略版として

この設計はクリーンアーキテクチャの考え方をベースにしている。

参考: The Clean Architecture – Clean Coder Blog

フルだと4層あって、PresenterやViewModelも分離する。 今回省略しているのはPresenterとUseCase interfaceの分離。 規模に対して過剰になる部分は省いた。

依存の向きだけは守った。それだけで十分にテスタブルな設計になった。

ただしこれを最初から理解して設計したわけじゃない。 「クリーンアーキテクチャっぽく、テスタブルにしたい」と言っただけで、 構造を作ったのはAIだ。自分は後から理解した。


「俺いる必要なくね?」に対する答え

Part3以降で手を放したせいで、設計の理解が抜けていた。 PolicyとVOの関係も、依存の向きも、Serviceの役割も、改めて問われると曖昧だった。

実装が自動化されることと、設計を理解していることは別の話だ。

AIが得意なのは仕様が決まった実装だ。 「何を仕様にするか」と「その設計が正しいかどうか」は人間が判断する必要がある。 今回それをサボっていた。

そしてもう一つ。 「NDL連携を入れる」「Reviewを今入れない」という判断はAIがしていない。 何を作るかを決めたのは自分だ。AIは決まったことを実装した。

「俺いる必要なくね?」の答えは、理解をサボったら本当にいらなくなる、だと思う。


まとめ

  • 最初の要件は「クリーンアーキテクチャっぽく、テスタブルにしたい」だけだった
  • 設計を遂行したのはAIで、自分は後から理解した
  • PolicyはVOと1-1ではない。複数EntityをまたぐときにPolicyが出てくる
  • 依存の向きは「Domainが外部を知らない」ではなく「外部がDomainを知っている」
  • Serviceの役割はフローだけ。依存の向きがわかって初めて見えた
  • このシリーズ自体がTDDだった。人間がテストケースになっていた
  • 実装を自動化しても、設計の理解は自分でやる必要がある

次のPartではUser・ReviewのDB拡張とReviewServiceを実装する予定。 今度は手を動かす部分を意識的に残す。