機能追加を止めずに全面リファクタ。AI 開発の品質基盤を整備

教育リファクタリング

概要

オンラインスクールを運営されているお客様の、受講生向けの予約と運営側の管理をまとめた業務システムの事例です。稼働開始から約 10 か月、毎週のように要望を受けて機能を足し続け、画面数も連携先(カレンダー・メール・SMS・決済・スプレッドシート)も当初の数倍になりました。この規模になると、速度を保ったまま品質を維持するには「人が気をつける」では足りなくなります。そこで、機能追加のペースを落とさずに、約 1 か月かけて品質を自動で守る基盤を整備しました。

このシステムは、設計と実装の大半をコーディングエージェント(AI)に任せて開発しています。AI 主体の開発は速い反面、人向けの規約文書を「毎回守り続ける」ことが構造的に苦手です。今回の整備は、その性質を前提に「AI が破ると自動で止まる仕組み」に置き換えることを軸にしました。

課題(このまま進めると起きること)

  • 同じ業務ルールを参照する箇所が増えるほど、1 か所の直し漏れが数字のずれにつながるリスクが高まる
  • 画面や処理が大きくなるほど、変更の影響範囲を人が追い切れなくなる
  • 「〜しないこと」という規約を文書に足しても、AI は文脈が増えるほど守れなくなり、レビューで同じ指摘が繰り返される

設計の原則

今回の整備は、次の 4 つの原則に沿って進めました。

  1. 規約は「読んで覚える」ものではなく「破ると落ちる」ものとして書く。文章のルールは暫定で、lint・型・テスト・DB 制約のどれかに変換できたら文章から消す
  2. 規約違反のエラーメッセージには「代わりに何を使うか」まで書く。AI はそれを読んで次の試行で自力で直す
  3. 不具合が起きたら「気をつける」で終わらせず、原因を構造(lint ルール・型・DB 制約・ラチェット)に変換してから閉じる
  4. ルールの定義は 1 か所にだけ置く。同じルールを 2 か所に書いたら片方が必ず古くなる

やったこと

1. 直呼びの禁止を lint に移す

文書に書いていた「〜は共通処理を使う」を、ESLint の no-restricted-imports / no-restricted-syntax / no-restricted-globals に移しました。通信・通知・日時変換・入力検証・エラー応答・スタイルの直書きなど 15 ルールで、どれもメッセージに代替手段を書いています。

eslint.config.mjs(抜粋)
'no-restricted-imports': ['error', {
  paths: [{
    name: 'date-fns', importNames: ['format'],
    message: '日時の表記は lib/jst-date の formatJst を使う(date-fns の format はローカル TZ で整形する)',
  }],
}],
'no-restricted-globals': ['error', {
  name: 'alert', message: '通知は hooks/useNotice + components/common/Notice を使う(alert は使わない)',
}],
'no-restricted-syntax': ['error', {
  selector: "CallExpression[callee.property.name='json'][callee.object.name='request']",
  message: '入力は lib/api/validate の parseJsonBody(request, schema) を通す(request.json() を直接呼ばない)',
}],

AI はこのメッセージを読んで、次の試行で formatJst や parseJsonBody に書き換えます。人がレビューで同じ指摘をする回数が減り、文書の規約は「lint で禁止」の一言に縮みました。

2. 中心(業務ルール)と入出力の境界を全方向で揃える

業務ルールは lib/domain/<領域>/ に置き、rules.ts(DB を触らない純関数)と service.ts(第 1 引数に依存 Deps を取る DB 境界)の 2 段にしました。クリーンアーキテクチャ的な多層(usecase / repository / entity)はこの規模では形骸化すると判断して採らず、2 段で止めています。

外部サービスはすべて Deps のポートとして受けます。本物は adminDeps() が動的 import で組み立て、テストは何もしない fakeDeps を渡します。

ポート 包む module
calendar Google Calendar API
drive Google Drive API
notify Slack / メール / SMS
sheet Sheets API / GAS webhook
payment Stripe(service が使う 4 関数だけ)

lib/domain/ から SDK を直接 import することは lint で禁止し(型だけの import は許可)、メッセージに「Deps のポートを通す」と書いています。入口は route・UI の hooks・運用スクリプト・cron の 4 種で、どれも「認可 → 入力検証 → service → 整形」だけを行います。

lib/domain/<領域>/service.ts の形
export async function reserve(deps: Deps, input: ReserveInput): Promise<Result<Reservation>> {
  const check = rules.canReserve(input, await deps.db.findSlot(input.slotId));
  if (!check.ok) return check; // { ok: false, kind, code, message }。HTTP への変換は route 側の 1 関数だけ
  const saved = await deps.db.insert(...);
  await deps.notify.slack(...); // テストでは fakeDeps で何もしない
  return { ok: true, value: saved };
}

3. 既存分はラチェットで凍結してから減らす

構造の移行は一度にはできません。そこで境界違反の件数を基準値ファイルに記録し、npm run lint で「増えたら落ちる・減らしたら基準値を下げる」ラチェットを入れました。基準値を上げるときは PR に理由を書いて手で直す運用です。

eslint.boundary-ratchet.mjs
export const BOUNDARY_RATCHET = {
  componentsCallingApiClient: 0, // UI 部品から通信クライアントを直接呼んでいるファイル数
  scriptsUsingSupabaseDirectly: 1, // service を通さず DB を直接触る運用スクリプト数
  componentsOverLineLimit: 0, // 400 行を超える UI 部品数
};
計測した項目 計測開始時 1 か月後
UI 部品から通信を直接呼んでいるファイル 22 件 0 件
400 行を超える UI 部品 12 件 0 件
service を通さず DB を直接触る運用スクリプト 7 件 1 件

同じ仕組みで、DB の行の形(snake_case)をアプリ側に漏らさないルールも運用しています。snake_case のプロパティは行型ファイルと DB 境界ファイルの allowlist だけで許し、それ以外は naming-convention でエラー。許した分の件数は CI の Step Summary に出して可視化しています。

4. API 応答の型を 1 か所にする

route の応答型を定義する場所を 1 つにし、サーバーと画面が同じ型を import します。成功応答は { ok: true, ... } に統一し、画面側でその場に応答の型を書くことは lint で禁止しました。service は HTTP の応答型を返さず、判定失敗は { ok: false, kind, code, message } で返し、HTTP への変換は 1 関数だけが行います。

5. テストは注入で差し替え、本物には送らない

依存は Deps の注入で差し替え、vi.mock を前提にしません。モックするのは自分が所有しない I/O(外部 API)だけで、DB は実際に動かします。フェイクの応答をなぞるだけの assert は書かず、境界値と判定順序を検証します。テストは元ソースと同じ階層に <元名>.<場面>.test.ts で置き、配置は lint で検査します。E2E は Playwright で主要な 3 フローだけを持ち、外部送信は環境変数で無効化し、作ったデータは自分で消します。

6. 判断は ADR に、構造は C4 に残す

規約を lint に移すと「何が禁止か」は機械が覚えますが、「なぜそう決めたか」はどこにも残りません。そこで設計判断は ADR(Architecture Decision Record)に 1 判断 1 ファイルで残しました。今回の整備で書いたものを含めて 17 本です。

docs/adr/0007-domain-layer-extraction.md(抜粋)
# 0007. route からドメイン層を分離する(DI・入力スキーマ・lib/domain)

## 決定
- DB 境界を持つ関数は依存を引数(deps)で受け、route は本物を注入して呼ぶ
- lib/domain/<領域>/ に rules.ts(純関数)と service.ts(DB 境界)の 2 段で置く

## 理由と却下した案
- 却下: クリーンアーキテクチャ的な多層(usecase / repository / entity)→ この規模では層が形骸化する
- 却下: DI コンテナ導入 → 関数引数で足りる

ADR の運用で決めていることは 3 つです。

  • 「やらないと決めたこと」と、その理由を必ず書く。半年後に同じ議論を繰り返さないため
  • 判断を覆すときは旧 ADR を書き換えず、新しい ADR を足して旧 ADR を「置換」にする。履歴が残る
  • コード内のコメントには ADR 番号と「なぜ」だけを書く。「何をしているか」はコードに語らせる

AI 主体の開発では、ADR はそのまま AI への入力になります。「なぜ多層にしなかったか」が文書にあれば、AI が次の実装で usecase 層を勝手に足すことがなくなります。

構造の全体像は C4 モデル(L1 コンテキスト → L2 コンテナ → L3 コンポーネント)で、ドリルダウンできる 1 枚の HTML として docs/architecture/ に置きました。各要素には根拠となるリポジトリ内のパス(evidence)を付けてあり、図とコードがずれていないかを確かめられます。主要なユースケース 11 本は、図の上で呼び出しの順序を再生できます。境界を跨ぐ変更(新しい外部連携・DB の追加)をしたら、ADR を書き、C4 を更新してから閉じる、という順を固定しています。

結果

  • 整備期間中も機能追加を止めなかった。この 1 か月のコミットは、機能追加 24・修正 38・構造の改善 81
  • 規約違反はレビューではなく CI で止まるようになり、AI がエラー文を読んで自力で直す往復が定着した
  • UI 部品からの通信直呼び 22 件、大型部品 12 件がいずれも 0 件になった
  • 自動テストは 154 ファイル。主要な 3 つの流れは Playwright で実際のブラウザ操作として検証する
  • 導入順は「直呼びの禁止(lint)→ ラチェット → domain 層」。lint は初日から効き、ラチェットで現状を凍結してから、数字を見ながら構造を移行できた

使った技術

Next.js(App Router) / TypeScript / Supabase(PostgreSQL) / ESLint(typescript-eslint) / Vitest / Playwright / GitHub Actions / Vercel

← プロジェクト一覧へ戻る