← Back to Medzoner

Go · Open source · Dependency injection

After Wire: compile-time dependency injection with whyor

Declare the graph. Generate the wiring. Ship ordinary Go. Why I built a DI generator that keeps application startup explicit—and where it still needs work.

Why keep compile-time DI?

I like constructors that tell me what a component needs. A repository takes a database connection. A handler takes its use case. The application entry point connects them. Dependency injection does not have to mean a runtime container or a service locator.

That is the part of Google Wire I wanted to preserve. Wire generates initialization code from provider declarations; the result is Go that can be reviewed and debugged normally. The repository was archived in August 2025. That left room to explore an alternative, not a reason to hide the wiring behind another framework.

whyor is my take on that approach: generic declarations, explicit constructors and generated Go. It is not an official continuation of Wire, and it is not a drop-in replacement.

Start with ordinary constructors

Here is a small, complete application. Its store is in memory: this example needs no database, credentials or external service. These files live in an existing Go module.

app.go

package main

type Config struct{ Name string }
type Store interface{ Message() string }
type MemoryStore struct{ cfg *Config }
type App struct{ Store Store }

func NewConfig(name string) *Config {
    return &Config{Name: name}
}
func NewStore(cfg *Config) *MemoryStore {
    return &MemoryStore{cfg: cfg}
}
func (s *MemoryStore) Message() string {
    return "Hello, " + s.cfg.Name
}
func NewApp(store Store) *App {
    return &App{Store: store}
}

Nothing here imports whyor. The constructor parameters describe the dependencies; the return types describe what the constructors provide. You could still wire these functions together by hand.

wire.go: declare the composition

//go:build whyor

package main

import "github.com/Medzoner/whyor"

var Providers = whyor.Set(
    NewConfig,
    NewStore,
    whyor.Bind[Store, *MemoryStore](),
)

func InitApp(name string) *App {
    panic(whyor.Build(Providers, NewApp))
}

The build tag keeps this declaration out of a normal build. The panic is a marker consumed by the generator, not a runtime initialization strategy. Bind makes the interface choice explicit; the generator verifies that the concrete type implements it.

main.go: keep the caller simple

package main

import "fmt"

func main() {
    fmt.Println(InitApp("Go").Store.Message())
}
go install github.com/Medzoner/whyor/cmd/whyor@v0.11.0
go get github.com/Medzoner/whyor@v0.11.0
whyor gen .
go run .
# Hello, Go

The output is the point

The generated whyor_gen.go is small enough to read:

// Code generated by whyor. DO NOT EDIT.

//go:build !whyor

package main

func InitApp(name string) *App {
    v1 := NewConfig(name)
    v2 := NewStore(v1)
    v3 := NewApp(v2)
    return v3
}

There is no container lookup at runtime. No reflection is needed for the DI wiring. Commit this file: ordinary application builds do not need to execute the generator. Generated variable names are an implementation detail, not an API to depend on.

Constructors and an injector declaration go through whyor gen to become plain Go constructor calls.
The graph is declared at development time; the application runs ordinary constructor calls.

Moving from Wire without rewriting constructors

The model is familiar, but the declaration API differs. A typical set-and-binding migration looks like this:

- //go:build wireinject
+ //go:build whyor

- import "github.com/google/wire"
+ import "github.com/Medzoner/whyor"

- wire.NewSet(NewConfig, NewStore)
+ whyor.Set(NewConfig, NewStore)

- wire.Bind(new(Store), new(*MemoryStore))
+ whyor.Bind[Store, *MemoryStore]()

- panic(wire.Build(Providers, NewApp))
+ panic(whyor.Build(Providers, NewApp))

Remove the obsolete Wire-generated file, regenerate, then run the same tests as before. Keep constructor implementations in untagged files: unlike Wire, whyor does not copy local helpers out of a tagged declaration file.

I used this approach in my own Go application, medzoner-go, with composition roots for the server, its mock-backed test server and database migrations. The migration included compilation, freshness checks, unit tests and HTTP scenarios. Initially failing scenarios also failed with the old Wire output: comparing that baseline helped separate application validation bugs from DI changes.

That is useful evidence, but it is an author's own application—not independent adoption, a benchmark or a production certification. The migration guide and case-study notes explain the boundaries.

What I wanted beyond basic wiring

  • Explicit generic providers: declare NewRepository[User] and NewRepository[Order]. Type constraints are checked by Go; dependencies come from the instantiated signatures.
  • Slice providers: whyor.Many[Handler](NewUsers, NewOrders) provides a slice in declaration order.
  • Interface choices: use explicit Bind, or opt into AutoBind when exactly one registered candidate implements the requested interface.
  • Visible graphs: whyor show prints a tree; -f json, -f dot and -f mermaid make the graph available to other tools.
  • Freshness checks: whyor check ./... fails when versioned output needs regeneration. It complements compilation and tests; it does not replace them.
$ whyor show .
InitApp
└── *App [NewApp]
    └── Store [Bind]
        └── *MemoryStore [NewStore]
            └── *Config [NewConfig]
                └── string [parameter name]

These features should help explain the application, not make its composition mysterious. Automatic binding remains opt-in, and ambiguous choices are errors.

Cleanup should not erase errors

Resource-owning providers may return a cleanup. Legacy func() callbacks remain supported; modern injectors can return whyor.Cleanup, a func(context.Context) error.

Cleanups run in reverse acquisition order. In modern mode, every acquired cleanup is attempted and errors are combined with errors.Join. The caller supplies the shutdown context. If initialization fails, errors from previously acquired cleanups are joined with the initialization error.

That keeps lifecycle ownership with the application instead of introducing a framework that controls serving and shutdown. The lifecycle example covers both modes and the failure path.

What is not finished

whyor is still pre-1.0. Generic provider arguments must be explicit, method and variadic providers are unsupported, and platform-specific wiring needs more validation. It is not intended to discover every constructor or provide implicit request/singleton scopes.

Recent stabilization work fixed typed values, generated-name collisions and output ownership checks. More work remains before promising a frozen API. I keep verified contracts separate from roadmap items.

I am not presenting faster startup, lower memory use or broad production reliability without measurements. The current value is explicit composition, readable output and actionable migration feedback.

Try one composition package

If you use Wire, start with one injector on a branch. Capture the baseline test results, migrate the declaration, inspect the output and run the same checks again. You do not have to migrate the whole application to evaluate the idea.

Interested in trying it?

Run the public demo, read the generated code and tell me what made adoption easy—or what blocked it.

Explore whyor on GitHub →

Run the demo · Report a small reproduction · Contribute

I maintain whyor. Independent feedback is welcome, particularly from existing Wire users. Please remove credentials and private application data from any reproduction.