- Book: Hexagonal Architecture in Go
- Also by me: The Complete Guide to Go Programming — the companion book in the Thinking in Go series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Your Go service uses a reflection-based DI container. It reads a
struct tag, looks up a type in a registry, and hands you the
dependency at runtime. It works on your laptop. Then you deploy,
and the service panics forty seconds after boot:
panic: no provider registered for port.OrderRepository
A provider was renamed in one package and never re-registered in
the container. The compiler had no opinion, because to the
compiler the container is a map[reflect.Type]any. The graph
that holds your whole service together was validated at runtime,
in production, by your customers.
Go does not need a runtime container for this. The dependency
graph is knowable at build time, and google/wire
resolves it there. You write provider functions, declare which
concrete adapters satisfy which ports, and wire generates the
plain Go that stitches them together. If a dependency is
missing, go generate fails on your machine, not main in
staging.
Wire is a code generator, not a container
The distinction is the whole point. A runtime container keeps a
registry and resolves types with reflection while the process
runs. Wire runs once, at build time, reads your provider
functions, and writes ordinary Go source. What ships is a
function that calls your constructors in order. No reflection, no
registry, no lookup on the hot path.
That means the generated wiring is code you can open and read. It
shows up in stack traces. It steps in a debugger. go vet checks
it. It is the code you would have written by hand if hand-wiring
thirty constructors did not rot the moment someone adds the
thirty-first.
Providers: constructors, nothing new
A provider is just a constructor. If your service already returns
its dependencies from New... functions, you already have
providers. Nothing about your adapter code changes.
// adapter/postgres/order_repo.go
package postgres
import "database/sql"
type OrderRepo struct {
db *sql.DB
}
func NewOrderRepo(db *sql.DB) *OrderRepo {
return &OrderRepo{db: db}
}
A provider can also return a cleanup function and an error. Wire
understands both. It threads the error through the generated
injector and collects cleanups in reverse order.
// adapter/postgres/db.go
package postgres
import "database/sql"
func NewDB(cfg Config) (*sql.DB, func(), error) {
db, err := sql.Open("pgx", cfg.DSN)
if err != nil {
return nil, nil, err
}
cleanup := func() { _ = db.Close() }
return db, cleanup, nil
}
Binding adapters to ports
Here is where hexagonal architecture and wire meet. Your domain
depends on a port — an interface. Your adapter is the concrete
type that satisfies it. Wire needs to know that when something
asks for the port, it should get the adapter.
The port lives with the domain:
// port/order_repository.go
package port
import (
"context"
"yourapp/domain"
)
type OrderRepository interface {
Save(ctx context.Context, o domain.Order) error
ByID(ctx context.Context, id string) (domain.Order, error)
}
The service depends on the port, never on *postgres.OrderRepo:
// app/order_service.go
package app
import "yourapp/port"
type OrderService struct {
repo port.OrderRepository
}
func NewOrderService(
repo port.OrderRepository,
) *OrderService {
return &OrderService{repo: repo}
}
wire.Bind connects the two. It says: *postgres.OrderRepo
satisfies port.OrderRepository, so use it wherever the port is
requested.
// wire_sets.go
package main
import (
"github.com/google/wire"
"yourapp/adapter/postgres"
"yourapp/port"
)
var postgresSet = wire.NewSet(
postgres.NewDB,
postgres.NewOrderRepo,
wire.Bind(
new(port.OrderRepository),
new(*postgres.OrderRepo),
),
)
Swapping the adapter is now a one-line change. Point the bind at
an in-memory repo for tests, at a different store for a different
deployment. The domain and the service never learn which side of
the port they got.
Provider sets group the graph
A provider set is a named bundle of providers and binds. You
group them by layer or by adapter, then compose sets into bigger
sets. This is what keeps wiring legible as the graph grows.
// wire_sets.go
var appSet = wire.NewSet(
app.NewOrderService,
)
var superSet = wire.NewSet(
ConfigSet,
postgresSet,
appSet,
NewApp,
)
ConfigSet might provide Config and derive postgres.Config
from it. postgresSet owns the database and repositories.
appSet owns services. superSet is the whole service. Each set
is a unit you can reason about on its own.
The injector: a stub you never fill in
You write the signature of the function you want and hand its body
to wire.Build. This file carries the wireinject build tag, so
the real compiler never sees it — only the wire tool does.
//go:build wireinject
// +build wireinject
package main
import "github.com/google/wire"
func InitializeApp(cfg Config) (*App, func(), error) {
panic(wire.Build(superSet))
}
The panic is a placeholder. It never runs. Wire reads this
function to learn the return type (*App), the input (Config),
and the set to draw from (superSet), then generates the real
body.
What wire writes
Run go generate ./... (or wire ./...) and you get
wire_gen.go, tagged !wireinject so it is what actually
compiles:
//go:build !wireinject
// +build !wireinject
package main
func InitializeApp(
cfg Config,
) (*App, func(), error) {
db, cleanup, err := postgres.NewDB(cfg)
if err != nil {
return nil, nil, err
}
orderRepo := postgres.NewOrderRepo(db)
orderService := app.NewOrderService(orderRepo)
application := NewApp(orderService)
return application, func() {
cleanup()
}, nil
}
Read that top to bottom. It is the code you would write by hand:
call the constructors in dependency order, short-circuit on the
first error, collect the cleanup. Wire did the topological sort
so you do not maintain the ordering by hand every time a
constructor gains an argument.
Your main calls it and nothing else knows wire exists:
// main.go
func main() {
cfg := LoadConfig()
application, cleanup, err := InitializeApp(cfg)
if err != nil {
log.Fatalf("init: %v", err)
}
defer cleanup()
application.Run()
}
Why compile-time beats a runtime container
A runtime container fails when the code runs. Wire fails when the
code builds. That single shift moves an entire class of bug from
your customers to your CI. Three concrete differences.
Missing dependencies are a build error. Forget to provide
*sql.DB and wire refuses to generate:
wire: no provider found for *sql.DB
needed by *postgres.OrderRepo
needed by yourapp/port.OrderRepository
It names the type and the chain that needed it. A reflection
container gives you that only when the request reaches the gap,
which may be a rare code path in production.
No reflection on the hot path. The generated injector is
direct function calls. There is no type registry to consult per
request, no reflect allocation, nothing to profile. The cost is
paid once, by the code generator, before the binary exists.
The wiring is greppable. A new engineer reads wire_gen.go
and sees exactly how the service is assembled. There is no magic
container to learn, no tag DSL, no registration order to reverse
engineer. It is Go calling Go.
The tradeoff is honest: you run a generation step, and you commit
wire_gen.go. For a service whose graph you assemble once at boot
and keep for the process lifetime (which describes most
hexagonal services), that is a good trade. Where wire does not fit
is truly dynamic wiring, where the set of dependencies is not
known until runtime. That is rare, and when you hit it you know.
Wire it into the build
Keep the generation reproducible with a go:generate directive
next to the injector, and install wire as a tool dependency so
the version is pinned in go.mod (Go 1.24+ tracks tools there):
//go:generate go run github.com/google/wire/cmd/wire
Run go generate ./... in CI before go build. If someone adds a
constructor argument and forgets to provide it, generation fails
and the pipeline stops. The graph is verified on every commit,
not on every deploy.
Once the graph is checked on every commit, adding the
thirty-first constructor stops being a leap of faith. You provide
it, regenerate, and the build tells you before staging ever does.
If this was useful
Keeping ports and adapters honest is easier when the wiring is
build-time code you can read, and wire fits the hexagonal layout
without leaking into the domain. Hexagonal Architecture in Go
walks the port-and-adapter boundaries this pattern serves — where
the seams go and how to keep them from drifting. The Complete
Guide to Go Programming covers the language and build-tool depth
underneath: build tags, go generate, interfaces, and the tool
directives that pin wire's version.

Top comments (0)