For a long time, compile-time DI in Go meant google/wire. But wire is no longer taking new features, and every so often the question comes up: what do you use instead?
This post introduces kanna-di, which I wrote to sit in that spot. Like wire, the generated code is plain Go and imports nothing at run time. The difference is that you register no providers.
What was tedious
Think about what you write for wire:
- a
wire.gowith//go:build wireinject, where you declare the injector's signature yourself -
wire.NewSetto collect providers, passed towire.Build -
wire.Bindfor every interface - and every time you add a constructor, you add it to some set
In other words, telling wire which functions to use is a separate job, living somewhere other than the code. Constructors already sit next to their types. You're listing them a second time.
How kanna-di thinks about it
With kanna-di, you mark the struct that will be the container, and that's it. It looks at each field's type, finds the constructor next to that type, follows that constructor's parameters recursively, and writes a constructor that calls everything in order.
Take a layered application. The register-user use case depends on a repository for persistence and a sender for the welcome mail. Each layer keeps its constructors next to its types, as usual:
// package user, the domain
type Repository interface {
Save(ctx context.Context, u User) error
Find(ctx context.Context, id ID) (User, error)
}
// package mail, the domain
type Sender interface {
Send(ctx context.Context, m Message) error
}
// package usecase
func NewRegisterUser(repo user.Repository, mailer mail.Sender) *RegisterUser
func NewGetUser(repo user.Repository) *GetUser
// package postgres, the infrastructure
func NewUserRepository(db *sql.DB) *UserRepository
// package sendgrid, an external API
func NewSender(cfg config.Config) *Sender
// package handler
func NewUser(registerUser *usecase.RegisterUser, getUser *usecase.GetUser) *User
// package config
func Load() (Config, error)
The composition root already has a struct that holds what the program is made of: the server. Mark it //kanna:container.
package main
//go:generate go tool kanna-di
//kanna:container
type server struct {
_ user.Repository `di:"with=postgres.NewUserRepository"`
_ mail.Sender `di:"with=sendgrid.NewSender"`
users *handler.User
}
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("POST /users", s.users.Register)
mux.HandleFunc("GET /users/{id}", s.users.Get)
return mux
}
func openDB(cfg config.Config) (*sql.DB, func(), error) {
db, err := sql.Open("pgx", cfg.DSN)
if err != nil {
return nil, nil, err
}
return db, func() { db.Close() }, nil
}
What only this root can decide is written next to it: which implementation serves each of the domain's two interfaces (with=), and how to open what the standard library provides (openDB). Nothing else.
go generate ./... writes di_gen.go:
// Code generated by kanna-di. DO NOT EDIT.
package main
import (
"example.com/shop/internal/config"
"example.com/shop/internal/handler"
"example.com/shop/internal/infra/postgres"
"example.com/shop/internal/infra/sendgrid"
"example.com/shop/internal/usecase"
)
// newServer initializes dependencies and constructs server.
func newServer() (*server, func(), error) {
cfg, err := config.Load()
if err != nil {
return nil, nil, err
}
db, cleanup, err := openDB(cfg)
if err != nil {
return nil, nil, err
}
repo := postgres.NewUserRepository(db)
mailer := sendgrid.NewSender(cfg)
registerUser := usecase.NewRegisterUser(repo, mailer)
getUser := usecase.NewGetUser(repo)
users := handler.NewUser(registerUser, getUser)
return &server{
users: users,
}, cleanup, nil
}
*usecase.RegisterUser comes from usecase.NewRegisterUser. *sql.DB comes from openDB, next to the container. user.Repository and mail.Sender come from the implementations the container named. config.Config is loaded once and shared by openDB and sendgrid.NewSender. Nothing is registered anywhere. When a constructor's parameters change, the next go generate changes the code that calls it.
func main() {
s, cleanup, err := newServer()
if err != nil {
log.Fatal(err)
}
defer cleanup()
log.Print(http.ListenAndServe(":8080", s.routes()))
}
How providers are found
"The function next to the type" has a precise definition. When a type is needed, kanna-di takes the first of these that has one:
- an argument of the constructor (
di:"arg") - the provider the container chose for the type (
di:"with=...") - an exported field of a struct the container embeds or declares as
_ - a function in the container's own package
- an exported function in the package declaring the type, when that package is in your module or go.work
Finding none stops generation. So do two at the same level. A helper in a testutil package or a fixture returning the same type changes nothing unless the container names it. The standard library and other modules are never searched: io does have a function returning an io.Writer, and you would not want it picked for you.
Errors say which field
The part of DI that eats your time is when resolution fails. kanna-di's errors trace the need back to the field it started from.
The most common mistake with an interface is forgetting with=:
./server.go:12:2: error: nothing provides mail.Sender, which usecase.NewRegisterUser needs
via server.users → handler.NewUser → usecase.NewRegisterUser
return one from a function in package main, take it with di:"arg", or choose a provider with di:"with=..."
When there are two candidates, both positions are shown along with how to choose:
./server.go:11:2: error: 2 functions in package usecase return *usecase.GetUser
usecase.NewGetUser (../../internal/usecase/get_user.go:12:6)
usecase.NewCachedGetUser (../../internal/usecase/get_user.go:30:6)
via server.users → handler.NewUser
choose one with _ *usecase.GetUser `di:"with=usecase.NewGetUser"`
Typos get a did you mean, and if the implementation chosen with with= does not satisfy the interface, the error names the missing method.
Taking a container apart in tests
This is where I think the difference from wire is largest.
In tests you want the repository swapped for an in-memory one and the mail sender for something that only records. With wire, that means another injector and another set. With kanna-di, you declare a container in a _test.go file and embed the production server.
//kanna:container
type testServer struct {
*server
_ user.Repository `di:"arg"`
_ mail.Sender `di:"arg"`
}
func TestRegisterUser(t *testing.T) {
sent := &mailtest.Recorder{}
s := newTestServer(memory.NewUserRepository(), sent)
srv := httptest.NewServer(s.routes())
defer srv.Close()
// POST /users ...
if len(sent.Messages) != 1 {
t.Fatalf("want 1 welcome mail, got %d", len(sent.Messages))
}
}
Embedding a container from the same package rebuilds it here, field by field. Because the two interfaces are now declared as arguments, the with= bindings are unused, and nothing needs *sql.DB or config.Config anymore. So openDB, config.Load, and sendgrid.NewSender are never called, and newTestServer returns neither a cleanup nor an error. In production the root chooses the implementations; in tests you pass them in. That's all.
The output goes to di_gen_test.go. The chicken-and-egg problem, where the test calls newTestServer before it has been generated, is handled by kanna-di: it type-checks once more with what it is about to write in place, so a call to a not-yet-generated constructor does not stop it.
In a real application
showcase is a gRPC todo service that uses kanna-di across its layers:
-
internal/diholds theConfigandInfracontainers;NewInfraassembles the pgx pool and the transactor - each operation in
usecaseis a container of its own;NewPutTodoandNewDeleteTodoare generated - the
handlercontainer bundles the use cases intoNewHandler
A use case looks like this:
//kanna:container
type DeleteTodo struct {
_ *infra.Deps // infra.NewDeps provides it; its Transactor and Todos fill the fields below
transactor tx.Transactor
todos repository.Todo
}
A struct in a _ field is not stored; it only serves the container. The use case takes exactly what it needs from what the infrastructure bundled.
A struct with no fields, such as the todov1.UnimplementedTodoServiceServer a gRPC handler embeds, is left at its zero value. No provider is needed for it.
Cleanup
Providers returning T, func() or T, func(), error have their cleanups collected into one. They run in reverse order, and when a provider fails, the cleanups of those that ran before it are called before the error is returned. Same behavior as wire. Anything taken as an argument or drawn from an embedded struct belongs to the caller and is never closed.
The honest part
- Requires Go 1.26. It is designed to be installed with
go get -tool - It is v0, written by one person, and the API still moves
- A generic struct cannot be a container: there is no way to choose its type arguments
- Within one directory, test containers can live in either the in-package test package or the external
_testpackage, not both - Rule 5 (searching the type's own package) only covers your module and go.work. Constructors in dependencies are never picked up automatically. This is deliberate
kanna-di is one of the kanna family of generators, but it stands on its own. It shares nothing with the other tools, and the generated code calls only your functions.
Try it
go get -tool github.com/go-kanna/di/cmd/kanna-di
Mark the struct with //kanna:container, add //go:generate go tool kanna-di, run go generate ./....
If you're coming from wire: the functions you listed in wire.NewSet are most likely already next to their types, so they're found as they are. What you bound with wire.Bind becomes a field on the container that names the provider directly:
_ user.Repository `di:"with=postgres.NewUserRepository"`
What used to live in two places, the Bind and the Set, is this one line. And you no longer write the injector's signature; it follows from the container struct.
The repository has an example/ directory with the layout from this post, runnable as is.
Disclosure: kanna-di is a rewrite of mickamy/injector, an earlier tool of mine. The idea is the same: a container struct, and constructors found next to their types. The
injecttag becamedi, the directive became//kanna:container, and embedded test containers and cleanup handling were added. injector is archived; development continues here.
Top comments (0)