Your database already defines your API. The types, the constraints, the relationships - they're all there. You're rewriting them by hand in Go structs, handlers, and validation tags.

kiln eliminates that work. You write a schema. You run one command. You get a complete Go HTTP API - models, validation, handlers, routing, OpenAPI spec.

kiln is not an ORM. It's not a framework. It's a compiler that reads your database schema and outputs plain Go code you can read, edit, and own.

I built kiln because I kept solving the same problem: translating database schemas into API code, then keeping them in sync as the schema evolved. Every rename, every new column, every constraint change meant updating structs, handlers, validation, filters, and the OpenAPI spec - hoping nothing fell through the cracks.

This is schema drift, and it's a systemic problem. Rename a column from username to handle, and suddenly you're chasing it through structs, validators, filters, OpenAPI, tests. Tests pass. CI is green. Production breaks.

kiln makes drift impossible by construction.

Who it's for

kiln is for CRUD-heavy APIs where the database schema drives the domain - internal tools, admin APIs, B2B backends, rapid prototyping. It's not for every project. It struggles when business logic dominates over data shape, when workflows span multiple tables, or when API endpoints don't map cleanly to tables.

That boundary is intentional. kiln does one thing well and stays out of the way for everything else.

From schema to running API

Take a simple users table. kiln supports PostgreSQL, MySQL, and SQLite - the generated code is the same regardless of database:

CREATE TABLE users (
  id    UUID PRIMARY KEY DEFAULT gen_random_uuid(),  -- or INTEGER PRIMARY KEY for SQLite
  email VARCHAR(255) NOT NULL UNIQUE,
  name  VARCHAR(100) NOT NULL,
  role  TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin'))
);

Run kiln generate and you get a full set of endpoints - paginated listing, creation with validation, get by ID, partial updates, and deletes:

GET    /api/v1/users           List (paginated, filterable, sortable)
POST   /api/v1/users           Create (validated)
GET    /api/v1/users/{id}      Get
PATCH  /api/v1/users/{id}      Update (partial)
DELETE /api/v1/users/{id}      Delete

Here's what you get in your project:

generated/
  models/          Request/response structs with validation
  store/           Type-safe database operations
  store/mappers/   Type mappers (yours to customise)
  handlers/        HTTP handlers
  router.go        Route registration
docs/openapi.yaml  OpenAPI 3.0 spec
cmd/server/main.go Runnable server (yours to customise)

Everything under generated/ stays in sync with your schema. The mappers, helpers, and server entry point are generated once and left alone - they're yours to customise. If a generated handler doesn't fit, you can disable it per-table and write your own while still using kiln's generated store and models.

The generated code is intentionally boring. No reflection, no framework-specific base types, no runtime magic. Just idiomatic Go you'd write by hand. Here's the model kiln generates from that schema:

type CreateUserRequest struct {
    Email string  `json:"email" validate:"required,max=255"`
    Name  string  `json:"name"  validate:"required,max=100"`
    Role  string  `json:"role"  validate:"omitempty,oneof=member admin"`
}

email is NOT NULL without a default, so it's required. VARCHAR(255) becomes max=255. The CHECK constraint becomes oneof=member admin. kiln read the schema and wrote the struct you'd write by hand - including the validation tags.

Your schema already knows more than you think

kiln derives everything structural from your database - types, validation, routes, OpenAPI. But it draws a hard line: behavior, workflows, and permissions are yours.

It doesn't try to generate business logic, cross-table invariants, or authorization rules. It generates the structural API layer only. If your table has a deleted_at TIMESTAMPTZ column, kiln generates soft delete behavior. If a foreign key points from posts.user_id to users.id, kiln generates GET /users/{id}/posts.

The schema already encodes these rules. kiln just reads them.

Foreign keys become nested routes. A foreign key from posts.user_id to users.id generates GET /users/{id}/posts. Even when column names don't match table names (like author_id referencing users), kiln resolves the relationship correctly by reading bob's generated relationship structs.

Junction tables become many-to-many endpoints. A table with a composite primary key and exactly two foreign keys is recognized as a junction table. kiln generates link, unlink, and list endpoints on both sides. A post_tags table gives you POST /posts/{id}/tags, DELETE /posts/{id}/tags/{tagId}, and GET /posts/{id}/tags - automatically.

CHECK constraints become validation. CHECK (role IN ('member', 'admin')) generates validate:"oneof=member admin". No config needed.

Nullable deleted_at enables soft deletes. DELETE sets the timestamp instead of removing the row. List queries exclude soft-deleted records. No config needed.

VARCHAR(N) becomes max-length validation. VARCHAR(255) generates validate:"max=255".

Database error codes, not string matching. Error detection uses database error codes so it works correctly across PostgreSQL, MySQL, and SQLite regardless of locale. A unique violation returns 409, a FK violation returns 422.

Regenerate, don't rewrite

Most code generators run once and hand you the output. Schema changes mean manual updates.

kiln is designed for repeated regeneration. Every auto-generated file has an embedded SHA-256 checksum. On regeneration, kiln compares the current file contents against the checksum - if you haven't touched the file, it gets overwritten with the new version. If you've edited it, kiln skips it with a warning:

  ⚠ SKIPPED  generated/store/users.go (user-modified; use --force to overwrite)
  ✓ generated/models/users.go
  ✓ generated/handlers/users.go
  ✓ generated/router.go
  ✓ docs/openapi.yaml

This means there are two categories of generated files. Most - models, store, handlers, router, OpenAPI spec - are regenerated every time and stay in sync with your schema automatically. But some files are generated once and then left alone: mappers (where you add computed fields or redact sensitive data), helpers (where you customise error formats and pagination), auth middleware, and your server entry point. These are yours. kiln creates the starting point, you own them from there.

The practical workflow is four steps:

  1. Update your schema (add column, new table, change constraint)
  2. Migrate the database
  3. Run kiln generate
  4. Restart server

One migration, one command, everything in sync.

How kiln and bob work together

Delete kiln from your system and the generated code still compiles, still runs, still works. There's no runtime dependency on kiln itself.

The only runtime dependency is bob, a type-safe query builder for Go. kiln leans on bob at two different stages:

Your Database Schema
      │
      ├──► bob reads schema ──► ./models/              (query builders - internal)
      │
      └──► kiln generates ──► ./generated/
                                  models/              (request/response structs)
                                  store/               (DB operations using bob)
                                  store/mappers/       (type mappers - yours to edit)
                                  handlers/            (HTTP handlers)
                                  router.go            (route registration)
                                  docs/openapi.yaml    (API spec)
                                  cmd/server/main.go   (runnable server - yours to edit)

At generation time, kiln runs bob's schema introspection to read your database. Bob produces Go structs that represent your tables, columns, types, relationships, and constraints. kiln reads these structs and uses them as the foundation for everything it generates - models, store operations, handlers, routes, and the OpenAPI spec. This is how kiln understands foreign keys, junction tables, and column constraints without writing its own database introspection layer.

At runtime, the generated store code imports bob's query builder to construct type-safe SQL. When you call h.store.Create(ctx, req), that's bob building and executing the query under the hood. It's not an ORM - there's no implicit loading, no session state, no migration system. It builds SQL queries with Go type safety and gets out of the way.

kiln handles the API layer. Bob handles the database layer. Neither depends on the other at runtime - the generated code depends on bob, not on kiln.

Why bob? If you haven't heard of bob, that's fair - it's less widely adopted than GORM or sqlc. kiln uses it for two reasons. First, bob has the best schema introspection in the Go ecosystem - richly typed Go structs that capture columns, constraints, relationships, and defaults across PostgreSQL, MySQL, and SQLite. kiln would have had to build all of that from scratch otherwise. Second, bob's query builder produces type-safe SQL without implicit behavior - no magic loading, no caching, no identity map. The generated store code reads like hand-written SQL wrapped in Go types.

You don't have to use all of it

This is where kiln diverges from most generators. You can turn off any layer you don't want:

generate:
  models: true     # request/response structs
  store: true      # type-safe DB operations
  handlers: false  # write your own handlers
  router: false    # wire routes yourself
  openapi: true    # free OpenAPI spec, always in sync

In practice, most teams generate models, store, and OpenAPI. Handlers are the layer closest to business logic, and most non-trivial tables outgrow generated handlers quickly as they pick up conditional writes, cross-table invariants, or side effects.

This also works per-table. Generate handlers for the simple CRUD tables, disable specific operations on the ones that need custom logic:

overrides:
  payments:
    disable: [create, update, delete]

Your custom handlers still call the same generated store. You get the type-safe database layer without the generated HTTP handling:

func (h *PaymentHandler) Charge(w http.ResponseWriter, r *http.Request) {
    var req models.CreatePaymentRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    // your business logic
    if err := h.billing.Authorize(r.Context(), req.Amount); err != nil {
        http.Error(w, "payment declined", http.StatusUnprocessableEntity)
        return
    }

    // then use kiln's generated store
    payment, err := h.store.Create(r.Context(), req)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(payment)
}

This is what a mature kiln project looks like: generated store and models everywhere, generated handlers for simple CRUD tables, custom handlers for anything with business logic.

How it compares to sqlc

sqlc kiln
Input Hand-written SQL queries Database schema
Output Go types + query functions Types + store + handlers + router + OpenAPI
You write SQL + handlers + router kiln.yaml
Runtime dependency None bob (query builder)

The tradeoff is control vs speed. sqlc optimises for query control - you write every SQL statement, you own every edge case. kiln optimises for time-to-API - you describe the schema, kiln handles the rest. If your API is mostly CRUD with a few custom endpoints, kiln gets you there faster. If every query is hand-tuned, sqlc gives you more room.

The honest tradeoffs

Generated handlers have a ceiling. A payments table where every create needs to call a billing provider, verify fraud scores, and emit events will outgrow a generated handler immediately. kiln's answer is: disable the generated handler, write your own, and keep using the generated store and models. But you should know upfront that this is the expected path for complex tables, not an edge case.

Regeneration has sharp edges. Renames and type changes regenerate correctly, but your write-once files (mappers, helpers) may reference old names. kiln won't touch those files, so you update them by hand. kiln doctor helps catch stale generated files, but it can't check your custom code. This is a human-in-the-loop safety model, not a guaranteed one.

Filtering defaults are permissive. By default, all non-hidden columns are filterable and sortable. For production APIs, you should lock this down with filterable_fields and sortable_fields to avoid exposing internal fields or hitting unindexed columns.

Getting started

go install github.com/fisayoafolayan/kiln/cmd/kiln@latest

kiln init
kiln generate
go run cmd/server/main.go

Three commands to a running API.

Next steps

If you've ever shipped a bug because your API structs drifted from your database, kiln was built for you. Write the schema, generate the API, and let the database be the source of truth it was always meant to be.

kiln is MIT licensed. Contributions welcome.