送信確認のメールが届きます。
お問い合わせ内容に応じて、24〜72時間以内に担当者よりご連絡いたします。
送信することで、当社の【プライバシーポリシー】および、Open Reach Techからのメール受信に同意したものとみなします。
プライバシー同意チェックボックスを選択してください。

components..title

components..description

components..title

components..description

送信確認のメールが届きます。
お問い合わせ内容に応じて、24〜72時間以内に担当者よりご連絡いたします。
送信することで、当社の【プライバシーポリシー】および、Open Reach Techからのメール受信に同意したものとみなします。
プライバシー同意チェックボックスを選択してください。

AIにコードを書かせると壊れる。それを「構成」で解決するフレームワークを作った

Jiro Yamamotoのプロフィール写真
Jiro YamamotoBackend Developer

AIエージェントに実装を任せると、単発では驚くほどいいコードが出てきます。問題は2回目以降です。似たような機能を頼んだのに、今回はファイル構成が違う。エラーの返し方も、バリデーションの置き場所も、命名も揺れる。 重要なのは一発の生成物のクオリティではなく、10回頼んで10回とも同じ形に着地するかどうかです。本記事では、設計思想と規約を「AIが読める形」で枠組み化し、仕様書からブレないコードを生成させるフレームワーク Hora AI Kit を、実際の生成コードのBefore/Afterとともに解説します。

Banner of AIにコードを書かせると壊れる。それを「構成」で解決するフレームワークを作った

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') },
      ],
    }
  }
}

schemaerrorCodeHash も返り値の形も一致。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のお問い合わせフォームよりお気軽にご連絡ください。