I built five code generators to delete Go boilerplate

작성자

카테고리:

← 피드로
DEV Community · Tetsuro Mikami · 2026-08-17 개발(SW)

Writing Go means writing the same mechanical code over and over: constructor wiring, conversions between domain types and API types, test fixtures, row scanning, message localization.

There are plenty of libraries that erase this with runtime reflection — but that moves failure to runtime and hides the behavior inside the library. So I went the other way: generate the code you were going to write anyway, and commit it to the repository.

GitHub logo go-kanna / kanna

Go code generators built on the standard library — DI, struct mapping, test fixtures, ORM. Each works on its own and emits plain Go with no runtime reflection.

kanna

CI codecov Go Reference License: MIT Release

Go code generators built on the standard library — DI, struct mapping, test fixtures, ORM, i18n. Each works on its own and emits plain Go with no runtime reflection.

Your structs are the source of truth. Point a generator at a package and it writes the code you would otherwise write by hand, so the output stays readable, debuggable, and free of anything to learn at runtime.

kanna-di

Wires a container struct from the providers it can find, and writes a plain constructor.

Install

go get -tool github.com/go-kanna/kanna/cmd/kanna-di

Enter fullscreen mode Exit fullscreen mode

Use

A provider is a top-level function whose first result is a named type, a pointer to one, or an interface, optionally followed by an error. Nothing needs to be registered — kanna-di finds them by scanning the packages you point it at.

A container is a struct whose fields carry a di tag.

package app
//go:generate go tool kanna-di

Enter fullscreen mode Exit fullscreen mode

The output is ordinary Go. No reflection, no round-trips through interface{} — it shows up in code review and git diff, and you can step through it in a debugger.

Tool What it does kanna-di generates constructor dependency wiring kanna-fixture generates test fixtures with every field filled kanna-mapper generates domain ↔ wire type conversion functions kanna-orm generates type-safe queries, row scanning, and relations kanna-i18n generates typed message constructors with translations embedded

All five install as independent go tools, so you can use just one. The input is the same for all of them: the struct declarations and struct tags you already have. Nothing gets registered anywhere.

Here is what each one does, shown with actual generated output.

kanna-di — constructor wiring

What it does: looks at a container struct’s fields, finds the functions that can build them, and writes a constructor that calls them in dependency order.

A provider is “a package-level function whose first return value is a named type, a pointer to one, or an interface” (a trailing error is fine). No registration — it scans the packages you point it at.

// app/app.go
package app

//go:generate go tool kanna-di ./...

type DB struct{}

func NewDB() *DB { return &DB{} }

type User struct{ db *DB }

func NewUser(db *DB) User { return User{db: db} }

type Container struct {
    User User `di:""`
}

Enter fullscreen mode Exit fullscreen mode

go generate ./... writes di_gen.go next to it:

// app/di_gen.go
// Code generated by kanna. DO NOT EDIT.

package app

// NewContainer initializes dependencies and constructs Container.
func NewContainer() *Container {
    db := NewDB()
    user := NewUser(db)

    return &Container{
        User: user,
    }
}

Enter fullscreen mode Exit fullscreen mode

If anything in the dependency chain returns an error, the constructor returns one too and propagates it.

Whatever cannot be inferred, you spell out with tags on the container struct:

Tag What it does di:"" resolve from whichever provider returns the field’s type di:"with=NewReadOnlyDB" name the provider to use di:"arg" make the dependency a constructor parameter di:"returns" make that type the constructor’s return type (an interface works) di:"embed" take a struct as a parameter and offer its exported fields as resolution sources

A //kanna:container comment can also set the constructor name, the return type, and whether a MustNew* variant is generated.

When several providers match, it does not pick one silently — you get an error with the field’s position and the list of candidates.

kanna-fixture — test fixtures

What it does: writes one constructor per struct in a package, with every field already filled. Tests state only the values they care about.

// model/user.go
package model

//go:generate go tool kanna-fixture -source ./model -destination ./fixture

type User struct {
    ID    int64
    Name  string
    Email string
    Age   int `fake:"{number:18,65}"`
}

Enter fullscreen mode Exit fullscreen mode

// fixture/fixture_gen.go
// Code generated by kanna. DO NOT EDIT.

package fixture

func User(setters ...func(m *model.User)) model.User {
    m := model.User{
        ID:    gofakeit.Int64(),
        Name:  gofakeit.Name(),
        Email: gofakeit.Email(),
        Age:   gofakeit.Number(18, 65),
    }
    for _, s := range setters {
        s(&m)
    }
    return m
}

Enter fullscreen mode Exit fullscreen mode

Using it:

u := fixture.User(func(m *model.User) { m.Email = "[email protected]" })

Enter fullscreen mode Exit fullscreen mode

Every exported struct is covered — there is nothing to opt into. That is what keeps fixtures from falling behind the model. Field names drive the inference, so Email gets gofakeit.Email() and Name gets gofakeit.Name(); a tag like fake:"{number:18,65}" overrides it.

Types that need more than gofakeit, like uuid.UUID, are handled too — and if the destination module does not require the package yet, the generator says so.

Note: Generation is deterministic; the values are not. A test that needs the same data twice should call gofakeit.Seed(n) in TestMain.

kanna-mapper — domain ↔ wire conversions

What it does: writes the conversion functions between types you own and types you don’t (protobuf output, DTOs). For fields Go cannot convert by itself, it calls a converter you registered once.

Register the conversions one time:

// lib/converters/converters.go
package converters

import "github.com/go-kanna/kanna/mapper"

func init() {
    mapper.Register(UUIDToString) // uuid.UUID → string
    mapper.RegisterE(uuid.Parse)  // string → uuid.UUID; this one can fail
}

Enter fullscreen mode Exit fullscreen mode

Then name the pairs to map:

// mapper/gen.go
//go:generate go tool kanna-mapper -types=model.Employee:*employeev1.Employee -converters=../lib/converters
package mapper

Enter fullscreen mode Exit fullscreen mode

The generated functions:

// mapper/mapper_gen.go
// EmployeeToEmployeev1 maps model.Employee to *employeev1.Employee.
func EmployeeToEmployeev1(src model.Employee) *employeev1.Employee {
    return &employeev1.Employee{
        Id:      converters.UUIDToString(src.ID),
        Name:    src.Name,
        Address: AddressToEmployeev1(src.Address),
    }
}

// EmployeeFromEmployeev1 maps *employeev1.Employee to model.Employee.
func EmployeeFromEmployeev1(src *employeev1.Employee) (model.Employee, error) {
    if src == nil {
        return model.Employee{}, nil
    }
    v1, err0 := uuid.Parse(src.GetId())
    if err0 != nil {
        return model.Employee{}, fmt.Errorf("map model.Employee.ID: %w", err0)
    }
    // ...
}

Enter fullscreen mode Exit fullscreen mode

What you get:

  • Both directions. To and From are generated together
  • Nested structs are walked recursively (AddressToEmployeev1 gets called for you)
  • Error handling for fallible conversions, with the failing field named in the message
  • nil pointer handling, returning the zero value
  • Protobuf getters: src.GetId() is used where it exists
  • map:"-" excludes a field, map:"FieldName" names its counterpart

kanna-orm — type-safe queries

What it does: generates per-table query factories, row scanning, relation eager loading, and automatic timestamps from annotated model structs.

//kanna:table opts a struct in; everything else is inferred from the fields, and the orm tag overrides only where the inference disagrees with your schema.

// model/model.go
package model

//kanna:table
type User struct {
    ID        int       // primary key, by name
    Name      string    // column "name"
    Email     string    `orm:"email_address"` // explicit column name
    CreatedAt time.Time // set automatically on create
    Posts     []Post    `orm:"has_many,foreign_key:user_id"`
}

//kanna:table
type Post struct {
    ID     int
    UserID int
    Title  string
    User   *User `orm:"belongs_to,foreign_key:user_id"`
}

Enter fullscreen mode Exit fullscreen mode

//go:generate go tool kanna-orm -source ./model -destination ./query

Enter fullscreen mode Exit fullscreen mode

Using it:

db := orm.New(sqlDB, orm.MySQL) // or orm.PostgreSQL

users, err := query.Users(db).Where("name LIKE ?", "A%").OrderBy("id").All(ctx)
posts, err := query.Posts(db).Preload("User").All(ctx)

err = db.Transaction(ctx, func(tx orm.Querier) error {
    return query.Users(tx).Create(ctx, &model.User{Name: "Alice"})
})

Enter fullscreen mode Exit fullscreen mode

What you get:

  • A factory returning orm.Query[T] per table
  • Row scanning generated for you (no more lining up rows.Scan arguments)
  • Relationshas_many / has_one / belongs_to / many_to_many, eager loading via Preload, plus Join / LeftJoin
  • TimestampsCreatedAt / UpdatedAt are recognized by name and set automatically
  • MySQL and PostgreSQL, with placeholder and upsert differences absorbed by the runtime
  • Transactions — query code takes an orm.Querier, so it reads the same inside and outside a transaction

The query builder, dialects, and transactions live in the orm/ runtime; the generated code is plain Go on top of it.

Note: The destination must be a different package from the models. Generating into the model package means one stale output can make the models themselves stop compiling.

kanna-i18n — typed messages

What it does: generates a typed constructor per message from a directory of locale files — and embeds the translations into the output, so nothing is read or parsed at runtime.

One file per language, named by its BCP 47 tag:

# locales/en.yaml
greeting: "Hello!"
hello: "Hello, {name}!"
items_count:
  plural:
    one: "You have {count} item."
    other: "You have {count} items."
total_price: "Total: {price:number}"
user:
  not_found: "User not found."

Enter fullscreen mode Exit fullscreen mode

//go:generate go tool kanna-i18n

Enter fullscreen mode Exit fullscreen mode

With the defaults, that one line reads locales/ and writes messages/i18n_gen.go:

// messages/i18n_gen.go
// Hello returns the "hello" message.
func Hello(name string) i18n.Message {
    return i18n.Message{Key: "hello", Args: []i18n.Arg{
        {Name: "name", Value: name},
    }}
}

Enter fullscreen mode Exit fullscreen mode

No setup on the calling side:

en := messages.Localizer(language.English)
fmt.Println(en.Localize(messages.Hello("World")))

Enter fullscreen mode Exit fullscreen mode

What you get:

  • Placeholders become parameters. {name} produces Hello(name string); forgetting one is a compile error
  • Plurals. A {count} parameter following CLDR plural categories — languages like Japanese, which only have other, are handled correctly
  • Locale-aware number formatting. {price:number} renders as 1.234,56 in German
  • Fallback, walking en-GBen → the default language
  • Missing translations warn; structural mismatches fail. A key absent from the default language, a plural shape that differs, a parameter that does not match — those stop generation. A translation that is merely late is a warning, and falls back at runtime

Since the translations are embedded, there is no YAML to ship with your deploy.

Install

Take only what you need:

go get -tool github.com/go-kanna/kanna/cmd/kanna-di
go get -tool github.com/go-kanna/kanna/cmd/kanna-fixture
go get -tool github.com/go-kanna/kanna/cmd/kanna-orm

# the two with a runtime take two lines
go get -tool github.com/go-kanna/kanna/cmd/kanna-mapper
go get github.com/go-kanna/kanna/mapper

go get -tool github.com/go-kanna/kanna/cmd/kanna-i18n
go get github.com/go-kanna/kanna/i18n

Enter fullscreen mode Exit fullscreen mode

mapper and i18n take two lines because the parts that depend on runtime values — the converter registry, and CLDR plural rules, number formatting, language fallback — live in a runtime package. The other three are complete with just the generated code.

Every generator has -check, so CI can verify the output is not stale:

go tool kanna-di -check ./...

Enter fullscreen mode Exit fullscreen mode

Status

It is v0.0.1: tests and CI are in place, but tag and directive syntax may still move. Go 1.25+, MIT licensed.

Every generator has a runnable example under examples/ in the repository, and CI regenerates all of them on every run and fails on any diff — so the output you saw above is what the generators actually produce.

https://github.com/go-kanna/kanna

원문에서 계속 ↗