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
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.
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 doesdi:""
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)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
}
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.
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"`
}
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.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."
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}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
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.