Dependency Injection with Uber/Fx

Web & API Engineering intermediate Building with AI ~20 min read

Overview

As Go services grow beyond a handful of packages, the main() function becomes a tangled mess of initialization: create the database pool, pass it to the user repository, pass that to the auth service, pass that to the HTTP handler, wire up the health check, start the metrics server. Each new dependency means threading another parameter through the chain. Uber’s Fx framework solves this by providing a dependency injection container that automatically wires providers to consumers based on their types.

The platform uses Fx to compose its backend services into a modular application. Each domain (auth, billing, search, admin) registers its dependencies as Fx providers, and the framework resolves the full dependency graph at startup. If a required dependency is missing or a circular dependency exists, Fx fails fast with a clear error message rather than panicking at runtime deep in a call stack.

This approach trades a small amount of explicit wiring for significant gains in modularity: adding a new service means writing a provider function and registering it with Fx, not modifying a dozen initialization sites. It also makes testing straightforward—swap a real provider for a mock by replacing one module in the Fx app definition.

Core Concepts

Providers and Constructors

An Fx provider is a function that takes its dependencies as parameters and returns the thing it constructs. Fx inspects the function signature to determine what it needs and what it produces. For example, func NewUserRepo(db *sql.DB) *UserRepo tells Fx: “I need a *sql.DB and I produce a *UserRepo.” Fx matches types to wire everything together.

Modules for Domain Boundaries

Fx modules group related providers into a single unit. A billing module might bundle the Stripe client, the subscription repository, and the billing service. Modules can be included or excluded from the app definition, making it easy to run a subset of the system for testing or to build different deployment configurations from the same codebase.

Lifecycle Hooks

Fx manages the full lifecycle of the application: it starts components in dependency order and stops them in reverse order. Components can register OnStart and OnStop hooks to handle initialization (like opening database connections or starting HTTP servers) and cleanup (like draining connections or flushing buffers). This eliminates ad-hoc shutdown coordination.

Type-Based Wiring

Unlike name-based DI containers (common in Java), Fx resolves dependencies purely by Go types. This means the compiler catches many wiring errors: if two providers produce the same type, Fx reports the conflict at startup. For cases where you need multiple instances of the same type (e.g., a read-only DB and a read-write DB), Fx provides named values via fx.Annotate.

How It Works

Application Bootstrap

The entry point creates an Fx app by composing modules. Each module contributes providers, lifecycle hooks, and invocations (functions that trigger side effects like starting an HTTP server). Fx builds the dependency graph, validates it, and then starts all components in the correct order.

func main() {
    fx.New(
        fx.Provide(newConfig),
        fx.Provide(newLogger),

        // Domain modules
        DatabaseModule,
        AuthModule,
        BillingModule,
        SearchModule,

        // HTTP server (consumes handlers from modules)
        HTTPModule,

        // Start the server
        fx.Invoke(startServer),
    ).Run()
}

var DatabaseModule = fx.Module("database",
    fx.Provide(newDatabasePool),
    fx.Provide(newMigrator),
)

var AuthModule = fx.Module("auth",
    fx.Provide(newUserRepo),
    fx.Provide(newAuthService),
    fx.Provide(newAuthHandler),
)

Provider Functions

Each provider is a standard Go constructor. Fx reads the parameter types to determine dependencies and the return types to know what the provider produces. Providers can return errors—if a constructor fails, Fx aborts startup with the error context.

func newDatabasePool(cfg *Config, lc fx.Lifecycle) (*pgxpool.Pool, error) {
    pool, err := pgxpool.New(context.Background(), cfg.DatabaseURL)
    if err != nil {
        return nil, fmt.Errorf("connect to database: %w", err)
    }

    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            return pool.Ping(ctx)
        },
        OnStop: func(ctx context.Context) error {
            pool.Close()
            return nil
        },
    })

    return pool, nil
}

func newUserRepo(pool *pgxpool.Pool) *UserRepo {
    return &UserRepo{pool: pool}
}

func newAuthService(repo *UserRepo, cfg *Config) *AuthService {
    return &AuthService{
        repo:      repo,
        jwtSecret: []byte(cfg.JWTSecret),
        tokenTTL:  cfg.TokenTTL,
    }
}

Handler Registration Pattern

HTTP handlers from different modules need to register their routes on a shared router. Fx’s fx.Annotate with group tags collects multiple handlers into a slice, which the HTTP module consumes to build the final router.

type RouteRegistrar interface {
    RegisterRoutes(r chi.Router)
}

// In AuthModule
fx.Provide(
    fx.Annotate(
        newAuthHandler,
        fx.As(new(RouteRegistrar)),
        fx.ResultTags(`group:"routes"`),
    ),
)

// In BillingModule
fx.Provide(
    fx.Annotate(
        newBillingHandler,
        fx.As(new(RouteRegistrar)),
        fx.ResultTags(`group:"routes"`),
    ),
)

// HTTP module consumes all route registrars
type HTTPServerParams struct {
    fx.In
    Registrars []RouteRegistrar `group:"routes"`
    Logger     *zap.Logger
}

func newHTTPServer(p HTTPServerParams) *chi.Mux {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    for _, reg := range p.Registrars {
        reg.RegisterRoutes(r)
    }
    return r
}

Testing with Module Replacement

In tests, you replace real modules with test doubles. Fx makes this straightforward: build a test app with the modules you want, swapping the database module for an in-memory version or the Stripe module for a mock. The type system ensures the test app’s dependency graph is still valid.

func TestAuthService(t *testing.T) {
    var authSvc *AuthService

    app := fxtest.New(t,
        fx.Provide(testConfig),
        fx.Provide(newLogger),
        TestDatabaseModule,  // uses testcontainers PostgreSQL
        AuthModule,
        fx.Populate(&authSvc),
    )
    app.RequireStart()
    defer app.RequireStop()

    token, err := authSvc.Login(ctx, "user@example.com", "password")
    require.NoError(t, err)
    require.NotEmpty(t, token)
}

Implementation Patterns

Config Struct Per Module

Rather than a single global config struct, each module defines its own config type and a provider that extracts it from the environment or a config file. This keeps module boundaries clean and makes it obvious what configuration each module requires.

Decorated Providers for Cross-Cutting Concerns

Fx’s fx.Decorate wraps existing providers with additional behavior. This is useful for adding instrumented database pools, traced HTTP clients, or cached repositories without modifying the original provider functions.

Conditional Modules

Feature flags or environment variables can control which modules are loaded. A worker process might skip the HTTP module entirely and only load queue consumers, while the API server skips the worker module. Both configurations share the same domain modules.

Common Pitfalls

Further Reading