AIにコードを書かせると壊れる。それを「構成」で解決するフレームワークを作った
AIエージェントに実装を任せると、単発では驚くほどいいコードが出てきます。問題は2回目以降です。似たような機能を頼んだのに、今回はファイル構成が違う。エラーの返し方も、バリデーションの置き場所も、命名も揺れる。 重要なのは一発の生成物のクオリティではなく、10回頼んで10回とも同じ形に着地するかどうかです。本記事では、設計思想と規約を「AIが読める形」で枠組み化し、仕様書からブレないコードを生成させるフレームワーク Hora AI Kit を、実際の生成コードのBefore/Afterとともに解説します。
AIにコードを書かせると壊れる。それを「構成」で解決するフレームワークを作った
本記事のコードは、実際に Hora AI Kit で構築した「AI開発見積もりアプリ(renchan バックエンド)」からの抜粋です。Before/After の Before も、実際に規約を読ませずに生成させた本物の出力です。
1. まず、共有できるはずの痛みから
AIエージェントに実装を任せると、単発では驚くほどいいコードが出てくる。問題は2回目以降だ。
似たような機能を頼んだのに、今回はファイル構成が違う。前回は services/ に切り出したロジックが、今回は resolver に直書きされている。エラーの返し方も、バリデーションの置き場所も、命名も、その日の気分で変わる。レビューのたびに「置く場所はそこじゃない」を言い続け、気づけば人間がAIの出力を整形する係になっている。
一発の生成物のクオリティではない。10回頼んで10回とも同じ形に着地するか。ここが崩れると、AIは開発速度をむしろ下げる。
2. 結論:仕様書1枚から、毎回同じ構成・同じ規約のコードが出る
作ったのは Hora AI Kit。ひとことで言うと、
設計思想と規約を「AIが読める形」で枠組み化し、仕様書からブレないコードを生成させるフレームワーク
まず全体像を丸ごと見てほしい。これが1つのバックエンドプロジェクトの構成だ。
renchan-ai-estimation-app/
├── CLAUDE.md ← ① AIの「目次」:タスク→規約の対応表
├── .claude/skills/ ← ② 規約・設計思想の層(ここがHoraKitの本体)
│ ├── query-resolver/SKILL.md # 「読み取りAPIはこう書く」
│ ├── mutation-resolver/SKILL.md # 「状態変更APIはこう書く」
│ ├── database-design/SKILL.md # 「テーブルはこう設計する」
│ ├── execution-placement-pattern/SKILL.md # 「同期API/ジョブ どちらに置くか」
│ ├── agent-loop/SKILL.md # 「LLMエージェントループはこう組む」
│ ├── shared-naming/ shared-errors/ coding-styles/ … # 全体に効く共通規約
│ └── (設計・DB・GraphQL・REST・ジョブ・テスト… 70以上)
│
├── server/graphql/ ← ③ 生成コードの層(規約が効いた結果)
│ ├── CustomerGraphqlServerEngine.js # エンドポイント=ロール+認証フィルタ
│ └── resolvers/customer/
│ ├── actual/… # 本番ロジック
│ └── stub/… # 同じ契約のダミー(フロント先行開発用)
├── app/
│ ├── agentLoops/ # LLMパイプライン(設計→見積もりを段階実行)
│ ├── jobs/ # BullMQ バックグラウンドジョブ
│ └── validator/forResolver/ # 入力バリデーション(resolverから分離)
└── sequelize/
└── models/ migrations/ seeders/ # データ層
先に1点だけ押さえてほしい。.claude/skills/(②) と 生成コード(③) が層として分離していること。なぜこの分離が効くのかは後述する。
3. ズームレベル①:構成 —「設計思想を置く層」と「コードの層」を分ける
従来のフレームワーク(Rails でも Nest でも)が枠組み化してきたのは、③のコードの構造までだった。「ここに resolver を置け」「モデルはこう書け」。
Hora AI Kit が枠組み化したのは、その一段上、②の「設計思想・規約」そのものだ。
.claude/skills/… 「なぜ・何を・どう書くか」を宣言的に記述した規約集(=AIへの指示書)server/app/sequelize/… その規約に従ってAIが生成した実コード
この分離があると何が起きるか。規約を1か所直せば、以降の生成物すべてが揃う。 「値を作れないときは例外でなく null を返す」を skill に書けば、次に生成される全コードがそれに従う。人間が毎回レビューで指摘していたことが、生成の前に効く。
そして②を束ねるのが CLAUDE.md の「タスク→規約」対応表だ。AIはいきなりコードを書かず、まずここで今回の作業に対応する規約を引く。
| Skill | When to use |
| --- | --- |
| `query-resolver` | 読み取り(Query)リゾルバの追加・編集。validate→find→format |
| `mutation-resolver` | 状態変更(create/update/delete/sign-in/upload)。1トランザクションで実行 |
| `database-design` | テーブル設計 — 正規化/型/状態表現/履歴 |
| `execution-placement-pattern` | 同期API か バックグラウンドジョブ か、実行場所を決める |
「読み取りAPIを足して」→ AIは query-resolver skill を読み込んでから書き始める。ここが再現性の入口だ。
4. ズームレベル②:規約の中身 — AIにこう指示している
では、実際の規約ファイルを1枚開いてみる。query-resolver skill の中核部分(実物)だ。
## Grand principle: a query resolver is a filled-in template
Every query resolver has the **same skeleton** — `schema` getter → `errorCodeHash`
→ `resolve()` → validation wiring → finders → `formatResponse()`.
Keep the skeleton identical so review attention goes straight to the
query-specific differences. Do not write it cleverly.
`resolve()` reads as a fixed pipeline:
1. **Validate** the input (returns an error **or `null`**; `throw` it when present).
2. **Read** the data (finders: `findX()` / `countX()`).
3. **Throw domain errors** for not-found / empty results.
4. **Format** the result to the GraphQL schema shape (`formatResponse()`).
- **No transaction.** Queries only read.
- **`resolve()` orchestrates; the small methods do the work.**
Do not inline a 60-line find into `resolve()`.
注目すべきは書き方だ。これは「サンプルコード」ではなく、宣言的なルールになっている。「resolve() は必ず validate→read→throw→format の固定パイプライン」「クエリはトランザクションを開かない」「メンバーの宣言順は固定」——。判断が揺れるポイントを、あらかじめ潰す指示として書かれている。
さらに shared-errors(値を作れないときは例外でなく null を返す)、shared-naming(クラス名は単数形 UpperCamelCase)、coding-styles(式の折り返し位置)といった共通規約が、全 skill の背後で常に効いている。
この宣言的規約が、次の生成コードにどう効くのかを見よう。
5. ズームレベル③:生成コード — 規約が効いた結果
同じ仕様を2回、AIに投げる。
仕様:「アクティブなロール単価を、
role:level:currencyごとに最新の1件へ重複排除して返す読み取りAPIを作って」
左が規約off(skill を読ませずに生成させた実際の出力)、右が**HoraKit(query-resolver skill を読ませた実際の生成物)**だ。
Before:規約off(skill を読ませずに生成)
// resolvers/estimationRoleRates.js
const { EstimationRoleRate } = require('../models')
const resolvers = {
Query: {
estimationRoleRates: async () => {
const rates = await EstimationRoleRate.findAll({
where: { isActive: true },
order: [['effectiveFrom', 'DESC']],
})
// role:level:currency ごとに最初(=最新)の1件だけ残す
const seen = new Set()
const latest = []
for (const r of rates) {
const key = `${r.role}:${r.level}:${r.currency}`
if (!seen.has(key)) {
seen.add(key)
latest.push(r)
}
}
return { roleRates: latest } // モデルインスタンスをそのまま返す
},
},
}
module.exports = resolvers
これ自体は動く。だが問題は「次に別のAPIを頼むと、また違う形で出てくる」ことだ。
- 置き場所・命名・エクスポート形式が仕様ごとに揺れる(今回は resolver map、次回は class かもしれない)
- 重複排除ロジックが
resolveに直書き → 単体テスト不能 - モデルインスタンスをそのまま返す → スキーマ形状と密結合
- バリデーション・エラー方針の置き場がその場の判断
After:HoraKit(query-resolver skill 適用後の実物)
まず全 resolver に共通の固定骨格がある。これは仕様に関係なく毎回同じ。
resolve() … 指揮者。下の3つを呼ぶだけの固定パイプライン
├─ validateInput() … 入力検証(別クラスの *InputValidator に委譲)
├─ findXxx() … 読み取り(トランザクションを開かない)
└─ formatResponse() … スキーマ形状へ整形(モデルを直接返さない)
そのうえで、dedupeToLatest / keepLatest / formatRoleRate はこの resolver 固有のヘルパーメソッドだ。骨格ではなく「今回の仕様(role:level:currency で最新に重複排除)」を担う部分で、骨格と明確に分かれている。
export default class EstimationRoleRatesQueryResolver extends BaseQueryResolver {
/** @override */
static get schema () {
return 'estimationRoleRates'
}
/** @override */
static get errorCodeHash () {
return { ...super.errorCodeHash }
}
// ── ① 固定骨格:resolve は validate → find → format を呼ぶだけ ──
/** @override */
async resolve () {
const validationError = this.validateInput()
if (validationError) {
throw validationError
}
const rates = await this.findActiveRoleRates()
return this.formatResponse({ rates })
}
// find:読み取り(トランザクションを開かない)
async findActiveRoleRates () {
return EstimationRoleRate.findAll({
where: { isActive: true },
})
}
// format:スキーマ形状へ整形(モデルを直接返さない)
formatResponse ({ rates }) {
return {
roleRates: this.dedupeToLatest({ rates }),
}
}
// ── ② この resolver 固有のヘルパー(各々が単体テスト可能)──
dedupeToLatest ({ rates }) {
const latestByKey = rates.reduce(
(accumulator, rate) => this.keepLatest({ accumulator, rate }), // ← reduce の中に if は無い
{}
)
return Object.values(latestByKey)
.map(rate => this.formatRoleRate({ rate })) // ← map の中も分岐なし
}
// 分岐は「高階関数の外」の名前付きメソッドへ押し出す → 単体テスト可能に
keepLatest ({ accumulator, rate }) {
const key = `${rate.role}:${rate.level}:${rate.currency}`
const existing = accumulator[key]
if (existing && existing.effectiveFrom.getTime() >= rate.effectiveFrom.getTime()) {
return accumulator
}
return { ...accumulator, [key]: rate }
}
formatRoleRate ({ rate }) { /* … 明示的にスキーマ形状へ整形(モデルを直接返さない)… */ }
}
規約が指示していた「固定パイプライン」「トランザクションを開かない」「resolve は指揮、実処理は小メソッド」「モデルを直接返さず整形する」が、そのまま出力に現れている。同じ AI が、同じ仕様から、規約の有無だけでここまで別物を出す。
なぜ「読みやすくてテストしやすい」のか — 規約が効いている3点
After のコードが Before と決定的に違うのは、見た目の綺麗さではなく、共通規約が可読性とテスト可能性を強制している点だ。
forなどの逐次処理を禁止(shared-statements)。反復は必ずmap/filter/reduce/Array.fromで書く。Before のfor (const r of rates)は、After ではreduce+mapになっている。状態を持つループが消え、「何を変換しているか」が式として読める。- 高階関数のコールバックの中に
ifを書かない(eslintで強制)。上のreduceのコールバックはthis.keepLatest(...)を呼ぶ1ステートメントだけ。分岐(「既存より新しければ差し替え」)は名前付きのkeepLatestメソッドへ押し出される。結果、その分岐ロジックは resolve から切り離され、keepLatest({ accumulator, rate })として単体テストできる。Before のようにforの中へ埋め込まれた分岐は、resolver 全体を動かさないとテストできない。 - 命名規約(
shared-naming:クラスは単数形 UpperCamelCase、メソッドは責務を表す動詞句)。dedupeToLatest/keepLatest/formatRoleRateという名前だけで各メソッドの責務が読める。1メソッド=1責務なので、テストも「その責務ぶんだけ」書けばよい。
つまり 「for禁止 → 高階関数」「高階関数内 if 禁止 → 分岐を名前付きメソッドへ」 という2段の規約が、resolve() を短い指揮者に保ちつつ、判断ロジックを個別にテスト可能な単位へ自動的に分解している。人間が「ここ切り出して」と毎回言わなくても、AIが規約に従って最初からそう書く。
再現性の証明:actual と stub が同じ骨格に着地する
決定的なのはここだ。同じ仕様を「本番用(actual)」と「フロント先行用のダミー(stub)」で別々に頼んでも、同じ骨格に着地する。
// stub/queries/EstimationRoleRatesQueryResolver.js(実物)
export default class EstimationRoleRatesQueryResolver extends BaseQueryResolver {
/** @override */
static get schema () { return 'estimationRoleRates' } // ← 契約(schema名)は完全一致
/** @override */
static get errorCodeHash () { return { ...super.errorCodeHash } }
/** @override */
async resolve () {
return {
roleRates: [
{ id: 3001, role: 'backend', level: 'senior', dailyRate: '80000', currency: 'JPY', effectiveFrom: new Date('2026-01-01T00:00:00.000Z') },
{ id: 3002, role: 'frontend', level: 'mid', dailyRate: '60000', currency: 'JPY', effectiveFrom: new Date('2026-01-01T00:00:00.000Z') },
],
}
}
}
schema も errorCodeHash も返り値の形も一致。DBの有無だけが違い、契約は完全に共有される。だからフロントは stub で先に開発し、後で actual に差し替えるだけで動く。「同じ仕様を2回投げても同じ構造に着地する」——これが、規約を層として分離したことの効果だ。
6. なぜこの構造なのか — 設計思想の回収
ここまで見せた具体から、中核価値を3つだけ回収する。
- 再現性 … 宣言的規約を、AIが生成の前に必ず引く(
CLAUDE.mdの対応表)。だから actual と stub すら同じ骨格になる。属人性ではなく規約が構造を決める。 - 保守性 …
resolve()は指揮者、判断はkeepLatest/formatRoleRateに分割。各メソッドが単体テスト可能で、規約を1か所直せば全生成物に波及する。 - 安全性 … トランザクション境界、認証フィルタ、入力バリデーションの分離が規約で固定される。「うっかり認証を忘れる」がAI生成で起きにくい(後述)。
抽象論を先に語らないのが要点だ。3つの価値は、すべて上の実物に紐づいている。
7. もう一歩:AIパイプラインも同じ枠組みで組める
「規約でCRUDが揃うのは分かった。では複雑なものは?」——このアプリの心臓部、LLMで開発見積もりを生成するパイプラインを、agent-loop skill で組んだ例を見せる。
要件テキストから「設計書生成(Phase A) → 工数・コスト算出(Phase B) → 決定論的コスト計算」を段階実行する。各段は Stage(進捗名)と Action(LLM呼び出し1回分)に分かれ、規約どおりの構造になっている。
// app/agentLoops/DevelopmentEstimationPipeline.js(実物・抜粋)
export default class DevelopmentEstimationPipeline extends BaseAgentPipeline {
/** 順序つきステージ。name は DB の progress_step にそのまま対応 */
get stageCtors () {
return [
{ name: 'design_overview', Ctor: DesignOverviewStage },
{ name: 'design_architecture', Ctor: DesignArchitectureStage },
{ name: 'design_flow_api', Ctor: DesignInterfaceStage },
{ name: 'design_operations', Ctor: DesignOperationsStage },
{ name: 'estimation_core', Ctor: EstimationCoreStage }, // Phase B
{ name: 'estimation_proposals', Ctor: EstimationProposalsStage },
{ name: 'cost_calculation', Ctor: CostCalculationStage }, // 決定論計算
]
}
}
各ステージの中身(Action)も、CRUD の resolver と同じ「テンプレートを埋める」思想で書かれる。
// app/agentLoops/actions/EstimationCoreAction.js(実物・抜粋)
export default class EstimationCoreAction extends BaseDevelopmentEstimationLlmAction {
get aiAgentId () { return DEFAULT_AI_AGENT.AI_AGENT_FOR_ESTIMATION_CORE.ID }
get outputTool () { return developmentEstimationCoreSchema } // 出力を関数スキーマで固定
buildInstruction ({ argumentHash, context }) {
return this.composeSections([
this.buildLanguageDirective({ context }),
this.renderPriorJson({ // Phase A 設計書全体を唯一の根拠に
label: 'Phase A 設計書全体(見積もりの唯一の根拠)',
value: this.buildDesignDocument({ argumentHash }),
}),
this.buildRoleCodeSection({ context }), // 単価解決に使うロールコード語彙
'# タスク\n設計書全体を根拠に taskBreakdown と manHourCalculation を生成すること。金額は算出しない。',
])
}
}
注目点:金額の最終計算はLLMにやらせず、決定論的な cost_calculation ステージに分離している。「LLMは設計と工数の推定、確定計算はコード」という切り分けも、execution-placement-pattern / agent-loop の規約に沿った設計だ。さらにパイプラインはチェックポイント再開に対応し、ジョブがリトライされても消費済みのLLM呼び出しを再消費しない(advanceStage が完了ステージをスキップする)。
CRUD もAIパイプラインも、**「基底クラス+テンプレートを規約どおり埋める」**という同じ骨格に乗る。だから種類の違うシステムでも、読み手の学習コストが増えない。
補足:認証も規約で固定される
エンドポイント=ロールで、認証フィルタは宣言的に書く。
// server/graphql/CustomerGraphqlServerEngine.js(実物・抜粋)
export default class CustomerGraphqlServerEngine extends BaseAppGraphqlServerEngine {
/** 認証をスキップする公開オペレーションだけを明示的に列挙 */
get schemasToSkipFiltering () {
return [
'signIn', 'submitEstimate', 'estimateSession', 'onEstimateProgress',
// …公開する操作だけをホワイトリスト。列挙漏れ=デフォルト認証必須
]
}
}
「公開操作をホワイトリストで明示し、それ以外は既定で認証必須」。AIが新しい resolver を足しても、明示的に公開リストへ入れない限り保護される。安全側に倒れる規約だ。
8. 試し方 と まとめ
Hora AI Kit は、renchan アーキテクチャの設計思想と規約を skill 群として枠組み化し、仕様書からブレないコードを生成させるための統合フレームワークです。
git clone https://github.com/openreachtech/hora-ai-kit
最小の使い方はシンプルで、「作りたいものの仕様」を渡すと、AIが CLAUDE.md の対応表から適切な skill を引き、規約に沿ったコードを生成します。生成後は npm run lint が通ることを完了条件にしています。
まだ育てている最中の枠組みです。「うちの規約もこう書けるか」「この層分けは効くか」——実際に触ってのフィードバックを歓迎します。ご相談は本HPのお問い合わせフォームよりお気軽にご連絡ください。