「横のガードレール」でAIにアーキテクチャを教えるのをやめた話
はじめに
AI駆動開発、やってますか?
Claude Code や Codex に「クリーンアーキテクチャ+DDDで実装して」と指示すると、最初はある程度きれいなコードが出てきます。でも1週間後、気づいたらドメイン層が infrastructure を import してたり、UseCase に presentation のロジックが混入してたり。
仕様駆動でも最初の構成は作れる。しかし人の介入なしには維持できない。
この問題、どう解決すればいいんでしょうか。
今回は、AIが暴走してもアーキテクチャが崩壊しない仕組みを TypeScript で実装した話をします。
「横のガードレール」とは
note では「縦と横のガードレール」という概念を書きました。
| 種類 | 問い | 守るもの |
|---|---|---|
| 横のガードレール | どう作るか | 非機能(実装品質・アーキテクチャ) |
| 縦のガードレール | 何を提供するか | 機能(ユーザー価値・ビジネスルール) |
横は静的解析で検証し、縦はテスト実行で検証します。
この記事で扱うのは「横のガードレール」。コードの構造・依存関係を静的に検査して、アーキテクチャの崩壊を防ぎます。
パッケージ構成
まず、守るべき対象を見せます。
server/src/
├── domain/ # ドメイン層
├── usecase/ # ユースケース層
├── presentation/ # プレゼンテーション層
├── infrastructure/ # インフラ層
├── composition/ # DI構成
└── ...
依存の方向は外から内へ。
presentation → usecase → domain ← infrastructure
クリーンアーキテクチャの基本ですね。でも、AIはこの境界を平気で破ります。「便利だから」と逆方向の import を入れてくるんです。
ESLintで守れる部分
レイヤー間の依存制約は ESLint + eslint-plugin-boundaries で守れます。
// .eslintrc.cjs
module.exports = {
plugins: ['boundaries'],
settings: {
'boundaries/elements': [
{ type: 'domain', pattern: 'src/domain/**' },
{ type: 'usecase', pattern: 'src/usecase/**' },
{ type: 'presentation', pattern: 'src/presentation/**' },
{ type: 'infrastructure', pattern: 'src/infrastructure/**' },
],
},
rules: {
'boundaries/element-types': ['error', {
default: 'allow',
rules: [
{ from: ['domain'], disallow: ['usecase', 'presentation', 'infrastructure'] },
{ from: ['usecase'], disallow: ['presentation', 'infrastructure'] },
{ from: ['presentation'], disallow: ['domain', 'infrastructure'] },
{ from: ['infrastructure'], disallow: ['usecase', 'presentation'] },
],
}],
},
};
これで「domain が presentation を import したら RED」は実現できます。実際に使っています。
他にも ESLint でできることはあります。
-
no-consoleでログ出力の統一 -
no-restricted-syntaxで特定パターンの禁止 -
no-restricted-importsで特定モジュールの禁止
じゃあ、なぜカスタムのガードレールを作ったのか。型情報付きのESLintルールで実現できるものもありますが、監視対象が増えると保守コストが跳ね上がるため、横断的な検査はガードレールに分離しています。
ESLintでは守れない検査
ESLint は「単一ファイル内のパターン検出」が得意です。でも、以下は苦手か不可能。
| 検査内容 | ESLintで可能か |
|---|---|
| レイヤー間の依存制約 | ✅ eslint-plugin-boundaries |
console.log 禁止 |
✅ no-console |
| OpenAPI仕様と実装の整合性 | ❌ 外部YAMLとの照合が必要 |
リポジトリの戻り値が Result<T> か |
❌ 型情報の解析が必要 |
| ドメインイベントに必須プロパティがあるか | ❌ 構造解析が必要 |
この「ESLintでは守れない部分」こそ、カスタムガードレールの出番です。
現在、serverパッケージだけで 250以上 のガードレールが動いています。レイヤー境界だけでなく、不変条件・冪等性・トランザクション境界など、DDDの実践を強制するものが大半です。その中からESLintでは実現できない代表的なものを紹介します。
カスタムガードレールの実装
例1: OpenAPI仕様との整合性チェック
OpenAPI仕様に定義されたルートが、実際に実装されているかを検査します。
tsoa や openapi-generator で生成すれば一致しますが、手書きルートが混ざるとズレるので、push前に静的にREDにできるガードレールが必要です。
/**
* @what OpenAPI仕様のルートが実装されているか照合
* @why 仕様と実装がズレるとクライアントが404になるため
* @failure 未実装ルートがある場合、一覧を出力して非0終了
*/
// OpenAPI仕様(YAML)からルート一覧を抽出
function collectSpecOperations(specPath: string) {
const document = yaml.load(fs.readFileSync(specPath, 'utf8'));
const operations = new Map();
for (const [url, methods] of Object.entries(document.paths)) {
for (const method of Object.keys(methods)) {
if (method === 'parameters' || method === '$ref') continue;
operations.set(`${method} ${url}`, { method, path: url });
}
}
return operations;
}
// 実装(TypeScript)からルート一覧を抽出
async function collectRouteDefinitions(routeModuleGlob: string) {
const files = await glob(routeModuleGlob);
const routes = new Map<string, { method: string; path: string }>();
for (const file of files) {
const sourceFile = ts.createSourceFile(file, ...);
// registerRoute('GET', '/api/users', ...) を検出
ts.forEachChild(sourceFile, (node) => {
if (isRegisterRouteCall(node)) {
const method = node.arguments[0];
const path = node.arguments[1];
routes.set(`${method} ${path}`, { method, path });
}
});
}
return routes;
}
// 差分を検出
const specRoutes = collectSpecOperations('openapi.yaml');
const implRoutes = await collectRouteDefinitions('src/routes/**/*.ts');
const missingRoutes = [...specRoutes.keys()].filter(key => !implRoutes.has(key));
ここでは registerRoute の第1/第2引数が文字列リテラルである前提で解析し、OpenAPI側は paths の parameters や $ref を対象外にしています。method名は大文字に正規化して比較します(GET/getの揺れを吸収)。
実行すると、違反があればこう出ます。
❌ OpenAPI operations without router coverage:
- POST /api/candidates/{id}/interviews
- DELETE /api/jobs/{id}
仕様に定義されたルートを実装してください。
AIは仕様ファイルを読まずに実装を書くことがあります。「インタビュー登録API作って」と言うと、OpenAPI仕様を確認せずに独自のパスで実装してしまう。このガードレールがあれば、仕様と実装のズレを push 前に検知できます。
例2: Result<T> 強制
リポジトリの戻り値が Result<T> になっているかを検査します。
neverthrow などを使えば書けますが、規約が徹底されないとすぐ逸脱するため、戻り値を型解析で強制しています。
Result<T, E> は「成功時の値T」と「失敗時のエラーE」を型安全に扱うためのラッパーです。try-catch ではなく、戻り値としてエラーを扱うことで、呼び出し側がエラーハンドリングを忘れることを防ぎます。
/**
* @what Domainリポジトリが Result<T> を返しているか検査
* @why 生のPromiseだとエラー分類ができず、呼び出し側の扱いが不整合になるため
* @failure Result を使っていないリポジトリを検出した場合に非0終了
*/
AIが書きがちなコード。
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// 呼び出し側で try-catch が散らばる
try {
const user = await userRepository.findById(id);
if (!user) throw new Error('User not found');
} catch (e) {
// エラーの種類が分からない
}
ガードレールが強制するコード。
interface UserRepository {
findById(id: string): Promise<Result<User | null, NotFoundError>>;
save(user: User): Promise<Result<void, ConflictError>>;
}
// 呼び出し側でエラー型が明確
const result = await userRepository.findById(id);
if (result.isFailure()) {
// NotFoundError などの業務エラーのみ返る
// DBエラーは例外としてスローされる(後述)
return handleBusinessError(result.error);
}
業務エラー(BusinessError)だけをResultで返すのがミソです。DBエラーなどのインフラ障害はResultに含めず、例外としてスローします。
この分類を型で強制することで、AIがエラーハンドリングで迷うことがなくなります。
TypeScriptの型情報を解析して戻り値を検査する必要があり、ESLintの守備範囲外です。
例3: ドメインイベントの構造検査
ドメインイベントに causationId / correlationId / emittedAt が含まれているかを検査します。
OpenTelemetry/CloudEventsは運用基盤として有効でもイベント定義の必須項目までは保証しないので、定義時点で因果メタを必須化します。
/**
* @what ドメインイベントに因果メタが付いているか検査
* @why 因果メタがないとイベント系列の追跡・再現ができないため
* @failure 必須プロパティを欠くイベントを検出した場合に非0終了
*/
ドメインイベントは「何が起きたか」を記録するもの。しかし、それだけでは「なぜ起きたか」「どの操作の結果か」が分からない。
// ❌ 因果メタがないイベント
class UserCreatedEvent {
constructor(
public readonly userId: string,
public readonly email: string,
) {}
}
// ✅ 因果メタがあるイベント
class UserCreatedEvent {
constructor(
public readonly userId: string,
public readonly email: string,
public readonly causationId: string, // このイベントを引き起こした操作のID
public readonly correlationId: string, // 一連の操作を紐づけるID
public readonly emittedAt: Date, // 発生時刻
) {}
}
障害調査やイベントリプレイの際に、因果メタがあるかないかで追跡可能性が大きく変わります。
runGuard:統一実行ラッパー
すべてのガードレールは runGuard を通して実行されます。これにより、ログ形式・終了コード・実行時間の記録が標準化されます。
/**
* @what ガードレールの実行を標準化し、結果をndjsonで記録
* @why ガードレールごとにログ形式がばらつくと集計・判定が不安定になるため
*/
export async function runGuard(options: RunGuardOptions) {
const { guardId, exec, packageName = 'server' } = options;
const startedAt = new Date();
const result = await exec();
const status = result?.status ?? 'ok';
// 全ガードレールが同じ形式でログを出力 → 集計・分析が可能に
await writeNdjsonLog({
runId: process.env.CONSTITUTION_RUN_ID,
guard: guardId,
package: packageName,
status,
durationMs: Date.now() - startedAt.getTime(),
});
if (status !== 'ok') process.exit(1);
}
ログは ndjson 形式で蓄積されます。
{"runId":"20251226T120000Z","guard":"server-openapi-route-coverage-guard","status":"error","durationMs":342}
{"runId":"20251226T120000Z","guard":"server-domain-repository-result-guard","status":"ok","durationMs":128}
@what / @why パターン
すべてのガードレールに @what / @why / @failure を書いています。
/**
* @what 何を検査するか(1行で)
* @why なぜこの検査が必要か(理由)
* @failure 違反時にどうなるか(結果)
*/
これは AIに読ませるためのコメント です。
AIがガードレールを修正するとき、この3つがあると「なぜこのルールがあるのか」を理解した上で作業できます。逆にこれがないと、AIは「とりあえず通るように」修正してしまい、ガードレールの意図が骨抜きになります。
自律駆動の話
最初からこれだけのガードレールがあったわけじゃないです。
ESLintだけで始めた
eslint-plugin-boundaries でレイヤー依存を守り、no-console でログを統一。これで十分だと思っていました。
ESLintでは守れない崩壊が起きた
// リポジトリが生のPromiseを返すようになった
async function findUser(id: string): Promise<User | null> {
// エラー分類ができない、呼び出し側でtry-catchが散らばる
}
// OpenAPI仕様にあるルートが実装されていない
// → クライアントが404になって初めて気づく
実際には週2〜3本のPRでズレが出始め、レビューで都度キャッチしていましたが、すぐに限界が来ました。ESLintが守る「コードパターン」の外側で、アーキテクチャの境界が曖昧になっていました。
カスタムガードレールを追加した
ESLintでは検出できない問題に対して、カスタムガードレールを追加していきました。
| 問題 | 追加したガードレール |
|---|---|
| OpenAPI仕様と実装がズレる | openapi-route-coverage-guard |
| リポジトリがResult<T>を返さない | domain-repository-result-guard |
| ドメインイベントに因果メタがない | domain-event-causation-guard |
ガードレールが設計を導き始めた
面白いことが起きました。
最初、AIは普通にルートを実装します。
Human: インタビュー登録APIを作って
AI: src/routes/interviews.ts を作成しました
POST /interviews で登録できます
prepush を実行すると RED。
❌ OpenAPI operations without router coverage:
- POST /api/candidates/{id}/interviews
仕様に定義されたルートを実装してください。
AIにこのエラーを見せると、こう返ってきます。
AI: openapi.yaml を確認しました。
POST /api/candidates/{id}/interviews が正しいパスですね。
修正します。
REDを3〜4回踏んだ後、AIは指示される前に仕様ファイルを確認するようになりました。
Human: 面接評価の更新APIを作って
AI: まず openapi.yaml を確認します。
PUT /api/interviews/{id}/evaluations が定義されています。
これに合わせて実装しますね。
コンテキストが空でも機能する
でも、これって「同じセッション内で学習しただけ」じゃないの?
試してみました。新しいセッションを開いて、コンテキストまっさらな状態で「インタビュー登録APIを作って」と指示する。AIは当然、独自のパスで実装してくる。prepush を実行すると RED。AIがエラーメッセージと @what / @why を読んで、「openapi.yamlを確認します」と言い出す。
私が教えたんじゃない。ガードレールが教えた。
仕様駆動の辛いところ、分かりますよね。毎セッション同じ説明をする。コンテキストが切れたらまたゼロから。ドキュメントを読ませても、次のセッションでは忘れてる。
自律駆動では、コンテキストがゼロでも RED が発生する。AIは自分で調べて、自分で直す。ガードレールがSSOTになって、コードベース自体がAIを教育する。
これが「自律駆動」の本質です。
ディレクトリ構成
ガードレールは constitution/legislative/ に配置しています。
constitution/
├── legislative/ # 立法(ガードレールの定義)
│ ├── manifests/ # マニフェスト(YAML)
│ └── taxonomy.yml # 分類体系
├── executive/ # 行政(実行手順)
└── judicial/ # 司法(CI policy)
「立法・行政・司法」という構成、note で書いた「三権分立」そのままです。
prepush への統合
ESLint も カスタムガードレールも、すべて prepush で実行されます。
npx nx run workspace:prepush
1つでも RED があれば push できません。AIが作ったコードも、人間が作ったコードも、同じルールで検査されます。
まとめ
- 仕様駆動でも最初の構成は作れる。しかし人の介入なしには維持できない
- ESLintで守れる部分はESLintに任せる
- ESLintでは守れない部分にカスタムガードレールを作る
-
@what/@why/@failureで AI に意図を伝える - ガードレールを育てると、設計も育つ
次回は「複数集約・トランザクション境界」について書く予定です。Saga/UoW が必要になる規模で、立法がどう変わるか。
思想的背景
この実装の思想的背景は note で書いています。
Discussion