エージェントに優しいアーキテクチャ
はじめに
エージェントが良いコードを書けるかどうかは、プロンプトの質だけでなく、コードベースの構造に大きく依存する。明確な境界、予測可能なパターン、機械的に強制されるルールがあるリポジトリでは、エージェントの出力品質が劇的に向上する。
本モジュールでは、エージェントが迷わず正しいコードを生成できるアーキテクチャ設計を学ぶ。
厳格な境界が助けになる理由
エージェントの特性
エージェントは以下の状況で良い結果を出す:
- パターンが明確: 「この層ではこう書く」というルールがある
- 参照例がある: 同じパターンの既存実装がある
- 制約が機械的に検証可能: 間違えたらすぐエラーになる
逆に、以下の状況では品質が低下する:
- 暗黙の規約が多い
- 例外だらけのルール
- 同じことの書き方が複数ある
境界の明確化
明確な境界:
domain/ → ビジネスロジック。外部依存なし。
api/ → HTTP 層。domain の interface を利用。
infra/ → 外部接続。domain の interface を実装。
曖昧な境界:
src/ → すべてが混在。何がどこに属するか不明確。
レイヤー分離と依存方向の強制
Clean Architecture の実践
外側 ← 依存方向 → 内側
[HTTP Handler] → [Use Case / Service] → [Domain Model]
↑ ↑ ↑
api/ domain/service/ domain/model/
| |
[DB Repository] [External API Client]
↑ ↑
infra/db/ infra/external/
ディレクトリ構造
src/
domain/
patient/
model.go # Patient 型、値オブジェクト
repository.go # Repository interface
service.go # ビジネスロジック
errors.go # ドメイン固有エラー
api/
handlers/
patient.go # HTTP ハンドラ
middleware.go # 認証、ログ等
router.go # ルーティング定義
infra/
database/
patient_repo.go # Repository 実装
migrations/ # DB マイグレーション
external/
insurance_client.go # 外部 API クライアント
依存ルールの機械的強制
ドキュメントに「domain は外部に依存しない」と書くだけでは不十分。エージェントはドキュメントを見落とすことがある。テストで強制する。
// tests/architecture/dependency_test.go
func TestDomainHasNoExternalDependencies(t *testing.T) {
pkgs, _ := packages.Load(&packages.Config{
Mode: packages.NeedImports,
}, "raica/idp/src/domain/...")
allowedPrefixes := []string{
"raica/idp/src/domain/",
"context",
"fmt",
"errors",
"time",
"strings",
// 標準ライブラリのみ許可
}
for _, pkg := range pkgs {
for _, imp := range pkg.Imports {
if !isAllowed(imp.PkgPath, allowedPrefixes) {
t.Errorf(
"ARCHITECTURE VIOLATION: %s imports %s\n"+
"Domain packages must only depend on standard library and other domain packages.\n"+
"If you need external functionality, define an interface in domain/ "+
"and implement it in infra/.\n"+
"Example: see domain/patient/repository.go (interface) and "+
"infra/database/patient_repo.go (implementation).",
pkg.PkgPath, imp.PkgPath,
)
}
}
}
}
"Boring" Technology の選択
なぜ「退屈な」技術がエージェントに優しいか
OpenAI の Harness チームが強調するポイント: composable で、安定した API を持ち、トレーニングデータに十分含まれている技術 をエージェントは最も上手く扱える。
| 特性 | Agent-Friendly | Agent-Unfriendly |
|---|---|---|
| API の安定性 | PostgreSQL, Redis | 最新の experimental DB |
| ドキュメントの充実度 | Go standard library | 社内独自フレームワーク |
| コミュニティの大きさ | React, Express | ニッチなフレームワーク |
| 予測可能性 | REST API | 独自プロトコル |
| 構成可能性 | Unix 哲学のツール | モノリシックなツール |
実践的な判断基準
新しい技術を導入するとき、以下を問う:
-
エージェントはこの技術のコードを正しく生成できるか?
- トレーニングデータに十分な例があるか
- ドキュメントが充実しているか
-
エラーメッセージは明確か?
- エージェントがエラーから自己修正できるか
-
既存のツールチェーンと統合できるか?
- linter, テストフレームワーク, CI のサポートがあるか
-
チーム全体が理解できるか?
- 特定の専門家しか扱えない技術は避ける
例: 技術選択の比較
HTTP Router の選択:
Option A: 標準の net/http + gorilla/mux
✅ 豊富なトレーニングデータ
✅ 安定した API
✅ 広く知られたパターン
→ エージェントが正確にコードを生成できる
Option B: 最新の高機能フレームワーク
❌ トレーニングデータが少ない
❌ API が頻繁に変わる
❌ 独自の概念が多い
→ エージェントが古い API を使ったり、間違ったパターンを生成する
Boundary Validation: 境界でデータを解析する
原則
外部からのデータは、システムの境界で検証し、内部の型に変換する。内部では常に検証済みの型を使う。
// API 層(境界)
func (h *PatientHandler) CreatePatient(w http.ResponseWriter, r *http.Request) {
// 1. リクエストを Parse
var req CreatePatientRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest,
"Invalid JSON body. Ensure Content-Type is application/json "+
"and the body is valid JSON.")
return
}
// 2. 境界でバリデーション
patient, err := req.ToDomain()
if err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
// 3. 以降は検証済みの domain 型のみを使う
result, err := h.service.CreatePatient(r.Context(), patient)
// ...
}
// リクエスト → ドメイン型への変換
func (r *CreatePatientRequest) ToDomain() (*domain.Patient, error) {
id, err := uuid.Parse(r.ID)
if err != nil {
return nil, fmt.Errorf(
"patient ID must be a valid UUID v4 (got: %q). "+
"Generate a UUID using uuid.New().", r.ID)
}
if r.Name == "" {
return nil, fmt.Errorf("patient name is required and must not be empty")
}
return &domain.Patient{
ID: domain.PatientID(id),
Name: r.Name,
}, nil
}
なぜこれがエージェントに優しいか
- パターンが明確: 「boundary で validate、内部では validated type」
- 参照例がある: 既存の handler が同じパターンに従っている
- テストしやすい: validation ロジックが独立しているため単体テストが書きやすい
共有ユーティリティの活用
問題: Hand-rolled helpers の増殖
エージェントは問題を解決するために、その場で helper 関数を作りがち。結果、似て非なる utility が散乱する。
// handler_a.go に書かれた helper
func parseUUID(s string) (uuid.UUID, error) { ... }
// handler_b.go に書かれた微妙に違う helper
func toUUID(s string) (uuid.UUID, error) { ... }
// handler_c.go にまた別の helper
func validateUUID(s string) (uuid.UUID, error) { ... }
解決策: pkg/ に共有ユーティリティを集約
// pkg/parse/uuid.go
package parse
// UUID は文字列を UUID に変換する。
// 無効な形式の場合、修正方法を含むエラーを返す。
func UUID(s string) (uuid.UUID, error) {
id, err := uuid.Parse(s)
if err != nil {
return uuid.Nil, fmt.Errorf(
"invalid UUID format: %q. "+
"Expected UUID v4 format like '550e8400-e29b-41d4-a716-446655440000'. "+
"Use uuid.New() to generate a new UUID.",
s,
)
}
return id, nil
}
CLAUDE.md に記載:
## 共有ユーティリティ
- UUID パース: pkg/parse.UUID() を使う。独自の UUID パース関数を作らない。
- エラーラップ: pkg/errors.Wrap() を使う。
- ページネーション: pkg/pagination.Parse() を使う。
ファイルサイズと命名規則
ファイルサイズの制限
大きなファイルはエージェントのコンテキストを圧迫し、的確な変更を難しくする。
ガイドライン:
- 1 ファイル 300 行以下を目安にする
- 500 行を超えたら分割を検討する
- 関数は 50 行以下を目安にする
悪い例:
handlers/patient.go (1200 行 - CRUD + 検索 + バリデーション + ヘルパー)
良い例:
handlers/
patient_create.go (80 行)
patient_read.go (60 行)
patient_update.go (90 行)
patient_search.go (100 行)
patient_validate.go (70 行)
命名規則
一貫した命名規則により、エージェントが新しいファイルの名前を予測できるようになる。
パターン: {entity}_{operation}.go
既存:
handlers/medication_create.go
handlers/medication_search.go
エージェントが新しいハンドラを作るとき、自然に:
handlers/patient_create.go
handlers/patient_search.go
と命名する。
テストファイルの命名
パターン: {対象ファイル名}_test.go
patient_create.go → patient_create_test.go
patient_search.go → patient_search_test.go
再実装 vs 外部ライブラリ
判断基準
| 観点 | 自前実装 | 外部ライブラリ |
|---|---|---|
| 機能が単純 | 推奨 | 過剰 |
| ドメイン固有 | 推奨 | 不適合な可能性 |
| 広く使われる汎用機能 | 車輪の再発明 | 推奨 |
| メンテナンスコスト | 自チーム負担 | コミュニティが負担 |
| エージェントの理解 | コードが見える | ドキュメント依存 |
実践的なルール
## ライブラリ選択の原則(CLAUDE.md に記載)
- UUID 生成/パース: github.com/google/uuid を使う
- HTTP ルーティング: 標準 net/http を使う
- JSON 処理: 標準 encoding/json を使う
- DB アクセス: sqlx を使う(独自 ORM を作らない)
- バリデーション: 独自の validation 関数を domain 層に書く
(汎用 validation ライブラリは使わない - ドメインルールが明示的になるため)
- ログ: 標準 log/slog を使う
アーキテクチャの機械的強制: まとめ
ドキュメントだけでは不十分。以下の手段で機械的に強制する:
| ルール | 強制手段 |
|---|---|
| 依存方向 | 構造テスト (architecture test) |
| 命名規則 | linter (revive, custom rules) |
| ファイルサイズ | CI でのチェックスクリプト |
| import ルール | go vet, custom analyzer |
| API パターン | テンプレート + テスト |
| エラーハンドリング | custom linter rule |
# CI で実行されるアーキテクチャチェック
make check-architecture
# 内部的には:
# 1. 依存方向テスト
# 2. ファイルサイズチェック
# 3. 命名規則チェック
# 4. import ルールチェック
Try This: アーキテクチャの改善
Exercise 1: 依存方向の確認
自分のプロジェクトの import グラフを確認する:
- domain 層が外部パッケージに依存していないか?
- handler 層が infra 層を直接参照していないか?
- 違反があれば、interface を導入して修正する
Exercise 2: ファイルサイズの監査
300 行を超えるファイルをリストアップする:
find src/ -name "*.go" -exec awk 'END{if(NR>300) print NR, FILENAME}' {} \;
分割候補を特定し、計画を立てる。
Exercise 3: 共有ユーティリティの整理
プロジェクト内で重複している helper 関数を特定する:
- 似た名前の関数を検索する
- pkg/ に統一版を作成する
- CLAUDE.md に「この utility を使うこと」と記載する
Exercise 4: 構造テストの追加
アーキテクチャルールを 1 つ選び、テストとして実装する。エラーメッセージには必ず:
- 何が間違っているか
- どう修正すべきか
- 参照すべきドキュメントやコード
を含める。
まとめ
エージェントに優しいアーキテクチャの原則:
- 厳格な境界: レイヤー間の依存方向を明確にし、機械的に強制する
- "Boring" Technology: 安定した、よく知られた技術を選ぶ
- Boundary Validation: 外部データは境界で検証し、内部では型安全に
- 共有ユーティリティ: 重複を避け、pkg/ に集約する
- 予測可能な構造: 一貫した命名規則、適切なファイルサイズ
- 機械的強制: ルールはテストと linter で強制する。ドキュメントだけに頼らない
次のステップ
- AI 開発ツールの使いこなし - 各ツールの効果的な使い方
- レビューと品質管理 - エージェント生成コードのレビュー