Go’s standard advice used to be “you don’t need dependency injection, just pass the struct.” Then the service grew to forty constructors, main.go became a hand-written wiring diagram eleven screens long, and every new handler needed three edits in three files. That is the moment teams reach for a DI library — and immediately disagree about which one.

The disagreement is real, because the three popular options are not variations on a theme. They solve different problems:

  • google/wire generates the wiring code at build time. Nothing runs at runtime.
  • uber-go/dig resolves the object graph at runtime with reflection.
  • uber-go/fx is dig plus an application lifecycle: modules, start/stop hooks, structured logging.

TL;DR: the verdict

Pick wire when you want zero runtime cost and errors at compile time. You write providers, run wire, and commit a generated wire_gen.go. If a dependency is missing, the build fails — not a 2 a.m. panic after deploy.

Pick dig when you need dynamic or config-driven wiring, or when you are wiring something that is not a plain application entry point: a plugin registry, a test harness that composes different subsets, a multi-tenant worker pool.

Pick fx when you want a framework, not a container: lifecycle hooks, module boundaries, and a startup log that shows exactly what got constructed. It is the right choice for a long-running server and the wrong choice for a small CLI.

Do not pick two. Mixing wire with fx in one binary means two graphs that can disagree, and the failure shows up as a nil pointer in production.

Comparison table (live GitHub data, November 2026)

google/wireuber-go/diguber-go/fx
Stars14,3774,5077,720
Last push2025-08-222025-05-132025-12-27
MechanismCode generation (wire_gen.go)Runtime reflectionRuntime reflection + lifecycle
Errors foundCompile timeRuntime (dig panics)Runtime, plus fx.ValidateApp
Runtime overheadNoneReflection at startupReflection + hook orchestration
Learning curveLow (one command)LowMedium-high
Lifecycle hooksNoNoYes (OnStart / OnStop)
Modules / scopingProvider setsNamed values, groupsfx.Module
Best fitServices, libs, CLIsTooling, plugins, testsLong-running servers
Generated files in repoYesNoNo

The star gap reflects age more than quality — wire was the default answer for years. The more useful signal is the mechanism column: generation, reflection, or framework.

Decision matrix

If this is your situationChooseBecause
Monolith with a 300-line main.gowireGenerated code is readable and greppable
Library or SDK shipped to other teamswireNo reflection, no hidden startup cost
CLI that should start in millisecondswireNo container initialization at all
Plugin system loading providers by configdigGraph built from data, not code
Tests that need different dependency subsetsdigBuild ad-hoc containers per test
HTTP server with DB pools and queuesfxHook ordering for graceful shutdown
Team unfamiliar with DI at allwireSmallest conceptual surface

google/wire: build-time generation

wire takes a list of constructors and writes the code you would have written by hand. Two files: the injector declaration and the generated implementation.

1
go install github.com/google/wire/cmd/wire@latest
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// providers.go
package main

import "database/sql"

type Config struct{ DSN string }

type Store struct{ db *sql.DB }
type Service struct {
    store *Store
    cfg   Config
}

func NewConfig() Config                  { return Config{DSN: "postgres://localhost/app"} }
func NewDB(cfg Config) (*sql.DB, error)  { return sql.Open("postgres", cfg.DSN) }
func NewStore(db *sql.DB) *Store         { return &Store{db: db} }
func NewService(s *Store, cfg Config) *Service { return &Service{store: s, cfg: cfg} }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// wire.go
//go:build wireinject
// +build wireinject

package main

import "github.com/google/wire"

func InitApp() (*Service, error) {
    wire.Build(NewConfig, NewDB, NewStore, NewService)
    return nil, nil
}

Run wire ./... and it emits wire_gen.go containing the literal construction order, including the error from sql.Open. The //go:build wireinject tag keeps the injector declaration out of normal builds, so the generated file is the only version that compiles into your binary.

Provider sets are how you keep this maintainable as the graph grows:

1
2
3
4
5
6
7
var StorageSet = wire.NewSet(NewDB, NewStore)
var AppSet = wire.NewSet(StorageSet, NewConfig, NewService)

func InitApp() (*Service, error) {
    wire.Build(AppSet)
    return nil, nil
}

The win is that a missing constructor is a build error with a readable message naming the type nobody provides. The cost is a generated file in your repository — which is a feature, not a bug: code review shows exactly how the app is wired, and git diff on wire_gen.go reveals accidental dependency changes.

For structs with many fields, wire.Struct(new(Server), "*") fills every field from the graph, and wire.Bind(new(Notifier), new(*EmailNotifier)) wires interfaces to implementations — which is the pattern that makes wire usable with hexagonal architectures.

uber-go/dig: reflection at startup

dig builds the same graph, but at runtime. You register constructors and then invoke a function; dig figures out what to pass.

1
go get go.uber.org/dig
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
    "log"

    "go.uber.org/dig"
)

func main() {
    c := dig.New()

    must(c.Provide(NewConfig))
    must(c.Provide(NewDB))
    must(c.Provide(NewStore))
    must(c.Provide(NewService))

    if err := c.Invoke(func(s *Service) {
        log.Printf("service ready with %d workers", s.workers)
    }); err != nil {
        log.Fatal(err)
    }
}

func must(err error) {
    if err != nil {
        panic(err)
    }
}

dig proves its value when the graph is data-driven. Parameter objects request several dependencies at once, and result objects publish several:

1
2
3
4
5
6
7
type HandlerParams struct {
    dig.In
    Store *Store
    Log   *log.Logger
}

func NewHandler(p HandlerParams) *Handler { return &Handler{store: p.Store, log: p.Log} }

Groups solve the plugin case that wire handles awkwardly — many implementations of one interface, collected into a slice:

1
2
3
4
5
6
7
c.Provide(NewEmailNotifier, dig.Group("notifiers"))
c.Provide(NewSlackNotifier, dig.Group("notifiers"))

type NotifierParams struct {
    dig.In
    Notifiers []Notifier `group:"notifiers"`
}

That is genuinely hard to express with compile-time generation, because the set is decided by registration order at runtime.

The trade-off is failure mode. dig reports a missing dependency as a runtime error when you call Invoke, and a constructor panic takes the process down at startup rather than failing a build. In a container with health checks this is survivable; in a library it is unacceptable.

uber-go/fx: dig plus an application lifecycle

fx wraps dig and answers the question dig ignores: what happens at startup and shutdown?

1
go get go.uber.org/fx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
    "context"
    "net/http"
    "time"

    "go.uber.org/fx"
)

func main() {
    app := fx.New(
        fx.Provide(NewConfig, NewDB, NewStore, NewService, NewHTTPServer),
        fx.Invoke(RegisterRoutes),
        fx.Invoke(StartServer),
        fx.WithLogger(func() fxevent.Logger { return fxevent.NopLogger }),
    )
    app.Run() // blocks, handles SIGINT/SIGTERM
}

func StartServer(lc fx.Lifecycle, srv *http.Server) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            go srv.ListenAndServe()
            return nil
        },
        OnStop: func(ctx context.Context) error {
            return srv.Shutdown(ctx)
        },
    })
}

Hook ordering is the reason teams adopt fx: dependencies start before their dependents and stop in reverse, so the database closes after the HTTP server drains. Writing that by hand in main.go is exactly the code people get wrong.

fx.Module gives you boundaries with visibility rules, and fx.Annotate + fx.As wire interfaces without a separate Provide line per implementation:

1
2
3
4
5
6
7
8
var HTTPServerModule = fx.Module("http",
    fx.Provide(
        fx.Annotate(NewRouter, fx.As(new(http.Handler))),
        NewHTTPServer,
    ),
)

app := fx.New(HTTPServerModule, fx.Provide(NewConfig, NewDB))

Always call fx.ValidateApp in tests. It builds the graph without running hooks, so a missing provider fails in CI instead of on deploy:

1
2
3
4
5
6
7
8
9
func TestAppWiring(t *testing.T) {
    err := fx.ValidateApp(
        fx.Provide(NewConfig, NewDB, NewStore, NewService, NewHTTPServer),
        fx.Invoke(RegisterRoutes),
    )
    if err != nil {
        t.Fatal(err)
    }
}

The cost of fx is indirection. A stack trace through an fx app has frames from the framework, dig reflection, and your constructor, and new team members spend their first week learning what fx.Invoke actually does. For a 200-line service, that cost is not repaid.

Pitfalls worth knowing before you commit

1. wire needs the build tag. Forgetting //go:build wireinject makes the injector compile into your binary and produce nil values at runtime. The generated file must be the only implementation in normal builds.

2. Regenerating wire_gen.go is a manual step. Nothing in go build runs wire. Wire it into CI (wire ./... && git diff --exit-code) or the graph silently drifts from the code.

3. dig panics on duplicate types. Two providers returning *Config are ambiguous. Use named values (dig.Name("readonly")) or distinct types — wrapping a primitive in a named struct is the cleaner fix.

4. fx hook errors abort startup, not shutdown. An error from OnStart stops the app with a partially started graph. Keep OnStart hooks minimal and idempotent.

5. Interface binding is verbose in both. wire.Bind, fx.Annotate(fx.As(...)), and a manual dig provider all exist because Go’s type system cannot express “this satisfies that” at the container level. Budget for it.

6. Tests are where DI pays for itself. The point of the container is not main.go; it is building a Service with a fake store in three lines instead of eleven. If your tests still construct everything by hand, you have added a dependency without collecting the benefit.

FAQ

Is Wire still maintained in 2026? The repository’s last push was in August 2025 and it remains widely used. It needs little maintenance because it generates plain Go code — there is no runtime library to keep in sync with language changes.

Can I use wire and dig together? You can, but you should not. Two graphs in one binary means two sources of truth, and mismatches surface as nil dependencies at runtime. Choose one per binary.

Does Dig use reflection at every request? No. Reflection happens during Provide and Invoke at startup. Resolved instances are stored in the container, so request-time lookups are map accesses.

How do I do graceful shutdown with Dig? dig has no lifecycle concept; you manage shutdown yourself in main or in a Runner struct you invoke. If graceful shutdown of many components is a requirement, use fx.

What about generics — do they change the picture? Generics make typed containers possible for small cases (type Container[T any]), but they do not replace graph resolution across many types. wire and dig remain the practical options.

Which one produces better stack traces? wire — by far. The generated code is ordinary function calls, so a nil pointer points directly at your wire_gen.go line. fx traces include framework frames and are noticeably harder to read.

For more Go engineering trade-offs, our Go SQL query builder comparison covers the layer directly below DI, and the Go CLI libraries guide shows how command wiring interacts with dependency graphs. If you are choosing test tooling for the constructors you are about to write, see the Go testing frameworks comparison.


💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com