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.
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
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
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…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:""`
}
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,
}
}
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}"`
}
// 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
}
Using it:
u := fixture.User(func(m *model.User) { m.Email = "known@example.com" })
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)inTestMain.
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
}
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
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)
}
// ...
}
What you get:
-
Both directions.
ToandFromare generated together -
Nested structs are walked recursively (
AddressToEmployeev1gets called for you) - Error handling for fallible conversions, with the failing field named in the message
-
nilpointer 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"`
}
//go:generate go tool kanna-orm -source ./model -destination ./query
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"})
})
What you get:
-
A factory returning
orm.Query[T]per table -
Row scanning generated for you (no more lining up
rows.Scanarguments) -
Relations —
has_many/has_one/belongs_to/many_to_many, eager loading viaPreload, plusJoin/LeftJoin -
Timestamps —
CreatedAt/UpdatedAtare 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."
//go:generate go tool kanna-i18n
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},
}}
}
No setup on the calling side:
en := messages.Localizer(language.English)
fmt.Println(en.Localize(messages.Hello("World")))
What you get:
-
Placeholders become parameters.
{name}producesHello(name string); forgetting one is a compile error -
Plurals. A
{count}parameter following CLDR plural categories — languages like Japanese, which only haveother, are handled correctly -
Locale-aware number formatting.
{price:number}renders as1.234,56in German -
Fallback, walking
en-GB→en→ 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
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 ./...
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.
Top comments (0)