DEV Community

Cover image for If you're looking for what comes after wire, take a look at kanna-di
Tetsuro Mikami
Tetsuro Mikami

Posted on Edited on AI-assisted

If you're looking for what comes after wire, take a look at kanna-di

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.go with //go:build wireinject, where you declare the injector's signature yourself
  • wire.NewSet to collect providers, passed to wire.Build
  • wire.Bind for 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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

*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()))
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. an argument of the constructor (di:"arg")
  2. the provider the container chose for the type (di:"with=...")
  3. an exported field of a struct the container embeds or declares as _
  4. a function in the container's own package
  5. 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=..."
Enter fullscreen mode Exit fullscreen mode

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"`
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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/di holds the Config and Infra containers; NewInfra assembles the pgx pool and the transactor
  • each operation in usecase is a container of its own; NewPutTodo and NewDeleteTodo are generated
  • the handler container bundles the use cases into NewHandler

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
}
Enter fullscreen mode Exit fullscreen mode

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 _test package, 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
Enter fullscreen mode Exit fullscreen mode

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"`
Enter fullscreen mode Exit fullscreen mode

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 inject tag became di, the directive became //kanna:container, and embedded test containers and cleanup handling were added. injector is archived; development continues here.

Top comments (0)