Introduction

As Go services grow beyond simple CRUD handlers, managing dependencies — database connections, configuration, loggers, HTTP clients — becomes increasingly complex. Passing everything through constructor parameters works for small projects but becomes unwieldy at scale. Dependency injection (DI) containers solve this by automating the wiring between components, reducing boilerplate and making your codebase more testable.

This article compares three Go DI approaches: Google Wire (14,402⭐) — compile-time code generation, Uber FX (7,578⭐) — runtime framework with lifecycle management, and samber/do (2,755⭐) — a lightweight generics-based container. We compare their philosophies, APIs, and real-world usage patterns.

Feature Comparison

FeatureGoogle WireUber FXsamber/do
ApproachCompile-time code genRuntime reflectionRuntime generics
GitHub Stars14,4027,5782,755
Lifecycle HooksManual (provider cleanup)OnStart/OnStopShutdown callbacks
Error DetectionCompile-timeRuntime (on invoke)Runtime (on invoke)
Interface BindingYes (explicit)Yes (annotated)Yes (implicit)
Lazy LoadingNo (all initialized)Yes (via annotated)Yes (lazy provider)
Module SystemProvider setsfx.ModuleNo (flat)
Configuration InjectionManualfxconfig integrationManual
Generated Code~200 lines wiring0 lines0 lines
Learning CurveHigh (DSL syntax)MediumLow (intuitive)
Go Generics RequiredNoNoYes (1.18+)

Google Wire: Compile-Time Safety

Google Wire takes the most radical approach: it’s a code generator, not a runtime library. You define providers (functions that create dependencies) and Wire generates the wiring code. The generated code is regular Go — you can read it, debug it, and it compiles with zero reflection overhead.

 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
32
33
34
35
36
37
38
39
40
41
42
43
// wire.go — provider definitions
//go:build wireinject
// +build wireinject

package main

import (
    "database/sql"
    "github.com/google/wire"
)

// Provider functions
func NewConfig() (*Config, error) {
    return LoadConfig("config.yaml")
}

func NewDB(cfg *Config) (*sql.DB, error) {
    return sql.Open("postgres", cfg.DatabaseURL)
}

func NewUserRepo(db *sql.DB) *UserRepo {
    return &UserRepo{db: db}
}

func NewUserService(repo *UserRepo) *UserService {
    return &UserService{repo: repo}
}

// Wire set: groups related providers
var ServiceSet = wire.NewSet(
    NewConfig,
    NewDB,
    NewUserRepo,
    NewUserService,
)

// Injector function — Wire generates this body
func InitializeApp() (*UserService, func(), error) {
    wire.Build(
        ServiceSet,
    )
    return nil, nil, nil
}

After running wire, the generated wire_gen.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// Code generated by Wire. DO NOT EDIT.

func InitializeApp() (*UserService, func(), error) {
    config, err := NewConfig()
    if err != nil {
        return nil, nil, err
    }
    db, err := NewDB(config)
    if err != nil {
        return nil, nil, err
    }
    userRepo := NewUserRepo(db)
    userService := NewUserService(userRepo)
    return userService, func() {
        db.Close()
    }, nil
}

Wire’s compile-time approach catches dependency cycles, missing providers, and type mismatches before your code runs. If you add a new dependency to UserService, Wire will refuse to generate code until all providers are satisfied. This eliminates an entire class of runtime errors.

For interface binding:

1
2
3
4
var RepoSet = wire.NewSet(
    NewPostgresRepo,
    wire.Bind(new(Repo), new(*PostgresRepo)), // Bind interface to implementation
)

Uber FX: Runtime Framework with Lifecycle

Uber FX is a full-featured application framework built around DI. Beyond constructor injection, it provides lifecycle management (startup hooks, graceful shutdown), configuration injection, and a module system for organizing large codebases.

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package main

import (
    "context"
    "database/sql"
    "go.uber.org/fx"
    "go.uber.org/fx/fxevent"
    "go.uber.org/zap"
)

func main() {
    app := fx.New(
        // Provide constructors
        fx.Provide(
            NewLogger,
            NewConfig,
            NewDatabase,
            NewUserRepo,
            NewUserService,
            NewHTTPServer,
        ),

        // Invoke to start the application
        fx.Invoke(func(server *HTTPServer) {
            server.Start()
        }),

        // Lifecycle hooks
        fx.Invoke(func(lc fx.Lifecycle, db *sql.DB) {
            lc.Append(fx.Hook{
                OnStart: func(ctx context.Context) error {
                    return db.PingContext(ctx)
                },
                OnStop: func(ctx context.Context) error {
                    return db.Close()
                },
            })
        }),

        // Logger configuration
        fx.WithLogger(func(log *zap.Logger) fxevent.Logger {
            return &fxevent.ZapLogger{Logger: log}
        }),
    )

    app.Run() // Blocks until signal received, then graceful shutdown
}

// Typed constructors
func NewDatabase(lc fx.Lifecycle, cfg *Config) (*sql.DB, error) {
    db, err := sql.Open("postgres", cfg.DatabaseURL)
    if err != nil {
        return nil, err
    }
    lc.Append(fx.Hook{
        OnStop: func(ctx context.Context) error {
            return db.Close()
        },
    })
    return db, nil
}

FX’s module system is particularly powerful for large applications:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var Module = fx.Module("users",
    fx.Provide(
        NewUserRepo,
        NewUserService,
        NewUserHandler,
    ),
    fx.Invoke(RegisterUserRoutes),
)

// Compose modules in main
fx.New(
    config.Module,
    database.Module,
    users.Module,
    orders.Module,
    fx.Invoke(StartServer),
)

FX supports value groups for plugin architectures — multiple providers can contribute values to a shared slice:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fx.Provide(
    fx.Annotate(NewAuthMiddleware, fx.ResultTags(`group:"middlewares"`)),
    fx.Annotate(NewLoggingMiddleware, fx.ResultTags(`group:"middlewares"`)),
)

// Consumer receives all middlewares
fx.Invoke(func(middlewares []Middleware `group:"middlewares"`) {
    for _, mw := range middlewares {
        router.Use(mw)
    }
})

samber/do: Lightweight Generics-Based Container

samber/do takes a minimalist approach — a single container struct that uses Go generics for type-safe resolution without any code generation or reflection. It’s the simplest to adopt if you want DI without framework overhead.

 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
32
33
34
35
36
37
38
39
40
package main

import (
    "database/sql"
    "github.com/samber/do/v2"
)

func main() {
    // Create the DI container
    injector := do.New()

    // Register providers
    do.Provide(injector, func(i *do.Injector) (*Config, error) {
        return LoadConfig("config.yaml")
    })

    do.Provide(injector, func(i *do.Injector) (*sql.DB, error) {
        cfg := do.MustInvoke[*Config](i)
        return sql.Open("postgres", cfg.DatabaseURL)
    })

    do.Provide(injector, func(i *do.Injector) (*UserRepo, error) {
        db := do.MustInvoke[*sql.DB](i)
        return &UserRepo{db: db}, nil
    })

    do.Provide(injector, func(i *do.Injector) (*UserService, error) {
        repo := do.MustInvoke[*UserRepo](i)
        return &UserService{repo: repo}, nil
    })

    // Resolve the top-level service
    service := do.MustInvoke[*UserService](injector)
    
    // Use the service
    service.CreateUser("alice@example.com")

    // Graceful shutdown
    injector.Shutdown()
}

samber/do supports named providers, health checks, and lazy loading:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// Named providers
do.ProvideNamed(injector, "read-db", func(i *do.Injector) (*sql.DB, error) {
    return sql.Open("postgres", cfg.ReadDatabaseURL)
})

do.ProvideNamed(injector, "write-db", func(i *do.Injector) (*sql.DB, error) {
    return sql.Open("postgres", cfg.WriteDatabaseURL)
})

// Health check integration
injector.HealthCheck() // Returns map[string]error for all registered checks

// Lazy provider: only constructed on first invocation
do.Provide(injector, do.WithLazy(func(i *do.Injector) (*ExpensiveService, error) {
    return NewExpensiveService(), nil
}))

For interface-based programming:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type UserStore interface {
    GetUser(id string) (*User, error)
    SaveUser(user *User) error
}

// Register implementation
do.Provide(injector, func(i *do.Injector) (UserStore, error) {
    return NewPostgresStore(db), nil
})

// Consumer depends on interface, not implementation
do.Provide(injector, func(i *do.Injector) (*UserService, error) {
    store := do.MustInvoke[UserStore](i) // Resolved by interface type
    return &UserService{store: store}, nil
})

Choosing the Right DI Approach

Use Google Wire when: You prioritize compile-time safety above all else. Large teams benefit from Wire’s explicit dependency graphs — CI catches wiring errors before deployment. The learning curve is steeper but pays off in production reliability for complex service graphs.

Use Uber FX when: You want a batteries-included framework with lifecycle management, configuration injection, and module organization. FX shines in microservice architectures where services need startup health checks, graceful shutdown, and clear separation of concerns. Uber itself uses FX across hundreds of microservices.

Use samber/do when: You need simple, zero-magic DI with minimal learning curve. The generics-based API is intuitive for Go developers. Ideal for small-to-medium services that don’t need framework-level lifecycle management. The health check feature is surprisingly useful for Kubernetes liveness probes.

Migration Patterns

If you’re starting with manual dependency injection and want to adopt a container:

  1. Start with samber/do — the lowest barrier. Wrap your existing constructors with do.Provide() calls. No code generation step, no build changes.

  2. Graduate to FX — when you need lifecycle management, configuration injection, or module organization. FX’s fx.Supply() lets you register already-constructed values alongside provider functions.

  3. Consider Wire — for high-reliability services where dependency misconfiguration in production is unacceptable. The switch to Wire requires a build step but the generated code is zero-overhead.

FAQ

Do I really need a DI container in Go?

For small services (under 10 dependencies), manual constructor injection works fine. DI containers become valuable when: (1) you have 20+ dependencies with multi-level wiring, (2) you frequently swap implementations for testing vs production, or (3) you need lifecycle management for graceful startup/shutdown.

How does DI affect testing?

It simplifies testing dramatically. With Wire, generate a test injector that substitutes mock providers. With FX, use fx.Replace() to swap implementations. With samber/do, use do.Override() to inject test doubles. All three approaches avoid global state, making parallel tests safe.

What’s the performance overhead of each approach?

Wire has zero runtime overhead — the generated code is plain Go function calls. FX has minor reflection overhead at startup (milliseconds for typical graphs); runtime resolution uses cached results. samber/do uses generics, which compile to concrete types with no reflection cost; the container map lookup has minimal overhead.

Can I mix these DI approaches in one project?

Technically yes, but avoid it. Each container manages its own lifecycle and dependency graph. Mixing them creates confusion about which container owns which dependency. Pick one approach per service or bounded context.

How do these compare to manual dependency injection in large Go projects?

Manual DI in large projects leads to main() functions with 50+ lines of constructor calls, implicit ordering requirements, and fragile startup sequences. A container makes the dependency graph explicit, ensures correct initialization order, and provides a single place to manage service lifecycle. The trade-off is an additional dependency and (for Wire) a code generation step in the build pipeline.

Why Self-Host Your DI Container Strategy?

Choosing the right DI approach is a foundational architecture decision that affects your entire service’s maintainability, testability, and operational reliability. Embedding this decision in your self-hosted Go services means your dependency management strategy is version-controlled, reviewable, and consistent across your codebase.

For microservice architecture patterns, see our self-hosted microservices frameworks guide. For project structure at scale, check our monorepo build systems comparison. For automated code generation workflows, see our code generation tools guide.


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