You’ll receive an email confirming your submission.
Our team will contact you within 24–72 hours, depending on the complexity of your request.
By submitting, you agree to our [Privacy Policy] and consent to receive updates or consultation support from Open Reach Tech.
Please select the privacy consent checkbox.

components..title

components..description

components..title

components..description

You’ll receive an email confirming your submission.
Our team will contact you within 24–72 hours, depending on the complexity of your request.
By submitting, you agree to our [Privacy Policy] and consent to receive updates or consultation support from Open Reach Tech.
Please select the privacy consent checkbox.

Let an AI write your code and it falls apart. We fixed it with structure.

Portrait of Jiro Yamamoto
Jiro YamamotoBackend Developer

Hand an implementation to an AI agent and the first result is often surprisingly good. The trouble starts on the second request. You ask for a similar feature and the file layout is different. Error handling, where validation lives, naming — all of it drifts. What matters is not the quality of a single generation but whether ten requests land on the same shape ten times. This article walks through Hora AI Kit, a framework that turns architectural intent and conventions into something an AI can read, so a spec produces code that does not drift — with a real before/after of generated code.

Banner of Let an AI write your code and it falls apart. We fixed it with structure.

Let an AI write your code and it falls apart. We fixed it with structure.

The code in this article is taken from a real application built with Hora AI Kit — an AI development-estimation app on a renchan backend. The "before" in the before/after is a genuine output too: what the same AI produced when we did not let it read the conventions.

1. Start with the pain we all share

Hand an implementation to an AI agent and the first result is often surprisingly good. The trouble starts on the second request.

You ask for a similar feature and the file layout is different this time. Logic that was extracted into services/ last time is written straight into the resolver now. How errors are returned, where validation lives, how things are named — it changes with the mood of the day. Every review turns into "that is not where this goes," and before long a human has become the formatter for the AI's output.

The quality of a single generation is not the issue. The question is whether ten requests land on the same shape ten times. Once that breaks, AI makes development slower, not faster.

2. The short version: one spec, and every generation follows the same structure and conventions

What we built is Hora AI Kit. In one line:

a framework that turns architectural intent and conventions into a form an AI can read, so a spec produces code that does not drift

Look at the whole picture first. This is the layout of one backend project.

renchan-ai-estimation-app/
├── CLAUDE.md                      ← ① the AI's table of contents: task → convention
├── .claude/skills/                ← ② the convention layer (this is HoraKit itself)
│   ├── query-resolver/SKILL.md    #   "this is how a read API is written"
│   ├── mutation-resolver/SKILL.md #   "this is how a state-changing API is written"
│   ├── database-design/SKILL.md   #   "this is how tables are designed"
│   ├── execution-placement-pattern/SKILL.md  # "sync API or background job?"
│   ├── agent-loop/SKILL.md        #   "this is how an LLM agent loop is built"
│   ├── shared-naming/ shared-errors/ coding-styles/ …  # conventions that apply everywhere
│   └── (design, DB, GraphQL, REST, jobs, tests … 70+)
│
├── server/graphql/                ← ③ the generated-code layer (the result)
│   ├── CustomerGraphqlServerEngine.js      # endpoint = role + auth filter
│   └── resolvers/customer/
│       ├── actual/…                        # production logic
│       └── stub/…                          # same contract, dummy data (for frontend-first work)
├── app/
│   ├── agentLoops/                # LLM pipelines (design → estimation, stage by stage)
│   ├── jobs/                      # BullMQ background jobs
│   └── validator/forResolver/     # input validation (kept out of the resolver)
└── sequelize/
    └── models/  migrations/  seeders/       # data layer

One thing to hold on to: .claude/skills/ (②) and the generated code (③) are separate layers. Why that separation matters comes next.

3. Zoom level ①: structure — separate "where intent lives" from "where code lives"

What traditional frameworks (Rails, Nest, take your pick) standardized was ③, the structure of the code: put the resolver here, write the model like this.

What Hora AI Kit standardizes is one level above that: ② the architectural intent and conventions themselves.

  • .claude/skills/ — a declarative body of conventions describing why, what and how to write (an instruction set for the AI)
  • server/ app/ sequelize/ — the actual code the AI generated by following those conventions

What does the separation buy you? Fix a convention in one place and every later generation lines up. Write "return null instead of throwing when a value cannot be produced" into a skill, and all code generated after that follows it. What a human used to point out in every review now applies before generation.

Tying ② together is the "task → convention" table in CLAUDE.md. The AI does not start writing code; it first looks up the convention that covers this piece of work.

| Skill | When to use |
| --- | --- |
| `query-resolver`    | Adding or editing a read (Query) resolver. validate→find→format |
| `mutation-resolver` | State changes (create/update/delete/sign-in/upload). One transaction |
| `database-design`   | Table design — normalization / types / state / history |
| `execution-placement-pattern` | Sync API or background job — deciding where it runs |

"Add a read API" → the AI loads the query-resolver skill before it writes anything. That is where reproducibility starts.

4. Zoom level ②: inside a convention — how we instruct the AI

Let us open one real convention file: the core of the query-resolver skill, verbatim.

## 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()`.

Notice how it is written. This is not sample code — it is a declarative rule. "resolve() is always the fixed pipeline validate → read → throw → format." "A query never opens a transaction." "Member declaration order is fixed." Every one of them closes off a point where judgement could drift.

Behind every skill, shared conventions are always in force too: shared-errors (return null rather than throwing when a value cannot be produced), shared-naming (class names are singular UpperCamelCase), coding-styles (where an expression wraps).

Now let us see what those declarative rules do to the generated code.

5. Zoom level ③: generated code — the conventions at work

We give the AI the same spec twice.

Spec: "Build a read API that returns active role rates, deduplicated to the latest entry per role:level:currency."

First without conventions (the actual output when the skill was not loaded), then with HoraKit (the actual output with the query-resolver skill loaded).

Before: conventions off (generated without loading the skill)

// resolvers/estimationRoleRates.js
const { EstimationRoleRate } = require('../models')

const resolvers = {
  Query: {
    estimationRoleRates: async () => {
      const rates = await EstimationRoleRate.findAll({
        where: { isActive: true },
        order: [['effectiveFrom', 'DESC']],
      })

      // keep only the first (= latest) entry per role:level:currency
      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 } // returns model instances as they are
    },
  },
}

module.exports = resolvers

This works. The problem is that the next API you ask for comes out in a different shape again.

  • Location, naming and export style drift per spec (a resolver map this time, maybe a class next time)
  • The dedup logic is inlined in resolveimpossible to unit test
  • Model instances are returned directly → tightly coupled to the schema shape
  • Where validation and error policy live is decided on the spot

After: HoraKit (the real output with the query-resolver skill applied)

First, there is a fixed skeleton shared by every resolver. It is the same every time, regardless of the spec.

resolve()          … the conductor. A fixed pipeline that only calls the three below
  ├─ validateInput()   … input validation (delegated to a separate *InputValidator)
  ├─ findXxx()         … reading (never opens a transaction)
  └─ formatResponse()  … shaping to the schema (never returns models directly)

On top of that, dedupeToLatest / keepLatest / formatRoleRate are helpers specific to this resolver. They carry this particular spec (dedupe to the latest per role:level:currency), and they are clearly separated from the skeleton.

export default class EstimationRoleRatesQueryResolver extends BaseQueryResolver {
  /** @override */
  static get schema () {
    return 'estimationRoleRates'
  }

  /** @override */
  static get errorCodeHash () {
    return { ...super.errorCodeHash }
  }

  // ── ① fixed skeleton: resolve only calls validate → find → format ──
  /** @override */
  async resolve () {
    const validationError = this.validateInput()
    if (validationError) {
      throw validationError
    }

    const rates = await this.findActiveRoleRates()

    return this.formatResponse({ rates })
  }

  // find: reading (never opens a transaction)
  async findActiveRoleRates () {
    return EstimationRoleRate.findAll({
      where: { isActive: true },
    })
  }

  // format: shaping to the schema (never returns models directly)
  formatResponse ({ rates }) {
    return {
      roleRates: this.dedupeToLatest({ rates }),
    }
  }

  // ── ② helpers specific to this resolver (each one unit testable) ──
  dedupeToLatest ({ rates }) {
    const latestByKey = rates.reduce(
      (accumulator, rate) => this.keepLatest({ accumulator, rate }), // ← no if inside reduce
      {}
    )

    return Object.values(latestByKey)
      .map(rate => this.formatRoleRate({ rate }))                    // ← no branching inside map either
  }

  // branching is pushed out of the higher-order function into a named method → unit testable
  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 }) { /* … shape to the schema explicitly (never return the model) … */ }
}

Everything the conventions asked for — the fixed pipeline, no transaction, resolve() conducts while small methods do the work, shape the result instead of returning the model — shows up directly in the output. The same AI, the same spec, and the presence of conventions alone produces something this different.

Why it is readable and testable — three conventions doing the work

What separates the "after" from the "before" is not how pretty it looks. It is that shared conventions force readability and testability.

  • Sequential loops are banned (shared-statements). Iteration is always map / filter / reduce / Array.from. The for (const r of rates) of the before becomes reduce + map. The stateful loop disappears and "what is being transformed" reads as an expression.
  • No if inside a higher-order function's callback (enforced by eslint). The reduce callback above is a single statement calling this.keepLatest(...). The branch — "replace it when the new one is newer" — is pushed out into a named keepLatest method. That branch is now detached from resolve and unit testable as keepLatest({ accumulator, rate }). A branch buried inside a for loop, as in the before, cannot be tested without running the whole resolver.
  • Naming conventions (shared-naming: classes are singular UpperCamelCase, methods are verb phrases naming their responsibility). dedupeToLatest / keepLatest / formatRoleRate state their responsibility in the name alone. One method, one responsibility — so a test only has to cover that responsibility.

Two layers of convention — "no for, use higher-order functions" and "no if inside them, push branches into named methods" — keep resolve() a short conductor while automatically decomposing decision logic into individually testable units. Nobody has to say "extract that" in review; the AI writes it that way from the start because the convention says so.

Proof of reproducibility: actual and stub land on the same skeleton

Here is the decisive part. Ask for the same spec twice — once as production (actual), once as a dummy for frontend-first work (stub) — and both land on the same skeleton.

// stub/queries/EstimationRoleRatesQueryResolver.js (verbatim)
export default class EstimationRoleRatesQueryResolver extends BaseQueryResolver {
  /** @override */
  static get schema () { return 'estimationRoleRates' }   // ← the contract (schema name) matches exactly

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

The schema, the errorCodeHash and the shape of the return value all match. Only the database is missing; the contract is shared completely. So the frontend builds against the stub first and swapping in the actual is all it takes. "The same spec lands on the same structure twice" — that is what separating conventions into their own layer buys you.

6. Why this structure — collecting the intent

From everything shown above, three core values are worth naming.

  • Reproducibility — the AI always pulls a declarative convention before generating (the table in CLAUDE.md). That is why even actual and stub share a skeleton. Structure is decided by the convention, not by whoever is at the keyboard.
  • Maintainabilityresolve() conducts; decisions live in keepLatest / formatRoleRate. Every method is unit testable, and fixing a convention in one place propagates to everything generated afterwards.
  • Safety — transaction boundaries, auth filters and the separation of input validation are fixed by convention. "We forgot the auth check" is far less likely to slip through AI generation (more below).

The point is not to lead with abstractions. All three values are tied to the real code above.

7. One step further: AI pipelines fit the same framework

"Fine, conventions keep CRUD consistent. What about the complicated parts?" Here is the heart of this application — a pipeline that generates development estimates with an LLM — built with the agent-loop skill.

From a requirements text it runs stage by stage: generate design documents (phase A) → derive effort and cost (phase B) → deterministic cost calculation. Each stage splits into a Stage (a progress name) and an Action (a single LLM call), exactly as the convention prescribes.

// app/agentLoops/DevelopmentEstimationPipeline.js (verbatim excerpt)
export default class DevelopmentEstimationPipeline extends BaseAgentPipeline {
  /** Ordered stages. `name` maps straight onto progress_step in the database */
  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 },     // deterministic
    ]
  }
}

The inside of each stage (the Action) is written with the same "fill in the template" thinking as a CRUD resolver.

// app/agentLoops/actions/EstimationCoreAction.js (verbatim excerpt)
export default class EstimationCoreAction extends BaseDevelopmentEstimationLlmAction {
  get aiAgentId () { return DEFAULT_AI_AGENT.AI_AGENT_FOR_ESTIMATION_CORE.ID }

  get outputTool () { return developmentEstimationCoreSchema } // output pinned by a function schema

  buildInstruction ({ argumentHash, context }) {
    return this.composeSections([
      this.buildLanguageDirective({ context }),
      this.renderPriorJson({                     // the whole phase A design doc as the only source
        label: 'Phase A design document (the only basis for the estimate)',
        value: this.buildDesignDocument({ argumentHash }),
      }),
      this.buildRoleCodeSection({ context }),     // role-code vocabulary used to resolve rates
      '# Task\nProduce taskBreakdown and manHourCalculation from the design document. Do not compute money.',
    ])
  }
}

Note this: the final money calculation is kept away from the LLM and isolated in the deterministic cost_calculation stage. "The LLM estimates design and effort; code does the definitive arithmetic" is itself a split prescribed by the execution-placement-pattern and agent-loop conventions. The pipeline also supports checkpoint resumption, so a retried job never re-spends LLM calls it already consumed (advanceStage skips completed stages).

CRUD and AI pipelines alike ride the same skeleton: a base class plus a template filled in according to the conventions. Different kinds of systems, and the reader's learning cost does not grow.

A note: auth is fixed by convention too

Endpoint equals role, and the auth filter is declarative.

// server/graphql/CustomerGraphqlServerEngine.js (verbatim excerpt)
export default class CustomerGraphqlServerEngine extends BaseAppGraphqlServerEngine {
  /** Only the public operations that skip authentication are listed explicitly */
  get schemasToSkipFiltering () {
    return [
      'signIn', 'submitEstimate', 'estimateSession', 'onEstimateProgress',
      // … a whitelist of public operations only. Not listed = authentication required
    ]
  }
}

Public operations are whitelisted; everything else requires authentication by default. When the AI adds a new resolver, it stays protected unless someone explicitly puts it on the public list. The convention fails safe.

8. Trying it, and a summary

Hora AI Kit packages the architectural intent and conventions of the renchan architecture as a set of skills, so that a spec produces code that does not drift.

git clone https://github.com/openreachtech/hora-ai-kit

The minimal usage is simple: hand it the spec of what you want built, and the AI pulls the right skill from the table in CLAUDE.md and generates code that follows the conventions. Passing npm run lint is the definition of done.

This framework is still growing. "Could our conventions be written this way?" "Does this layering hold up?" — we welcome feedback from people who actually try it. Reach us through the contact form on this site.