DEV Community

Cover image for google/wire in Go: Compile-Time DI for Hexagonal Services
Gabriel Anhaia
Gabriel Anhaia

Posted on

google/wire in Go: Compile-Time DI for Hexagonal Services


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

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

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

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

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

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

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

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

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

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

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

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

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.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)