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.
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]andNewRepository[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 intoAutoBindwhen exactly one registered candidate implements the requested interface. - Visible graphs:
whyor showprints a tree;-f json,-f dotand-f mermaidmake 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.
Run the public demo, read the generated code and tell me what made adoption easy—or what blocked it.
Explore whyor on GitHub →I maintain whyor. Independent feedback is welcome, particularly from existing Wire users. Please remove credentials and private application data from any reproduction.