go2026년 5월 24일16 min read

Getting Started with Go Dependency Injection using uber/fx

How to use uber/fx to automatically wire dependencies in a Go application and manage their lifecycle. Covers advanced patterns like fx.Module, fx.Decorate, and fx.Annotate, along with testing strategies, all through hands-on examples.

FFrank Advenoh
#Golang#uber/fx#Dependency Injection

1. Introduction

As a Go application grows, assembling dependencies becomes complex. You have to call constructors one by one in main(), line up parameter orders, and manage the lifecycle yourself. uber/fx is a DI (Dependency Injection) framework for Go that solves this problem.

The scope of this article is as follows.

  • Basic API: fx.Provide, fx.Invoke, fx.Supply, fx.New
  • Lifecycle management: fx.Lifecycle (OnStart/OnStop)
  • Grouping and extension patterns: fx.Module, fx.Decorate
  • Multiple instances of the same type: fx.Annotate + name: / group: tags (fx.Group)
  • Module encapsulation: fx.Private
  • Testing strategies: fxtest.New, fx.Replace, fx.Populate

2. fx Basics

2.1 Why DI Is Needed in Go

A real-world project with separated layers has many dependencies. For example, to construct an Article API, you must assemble the following dependencies in order.

// Manual DI: assembled directly in main()
cfg, _ := config.New()
db, _ := database.New(cfg)

authorRepo := author.NewMysqlAuthorRepository(db)
articleRepo := article.NewMysqlArticleRepository(db)

timeout := time.Duration(cfg.Context.Timeout) * time.Second
articleUsecase := article.NewArticleUsecase(articleRepo, authorRepo, timeout)

e := NewEcho()
article.NewArticleHandler(e, articleUsecase)

e.Start(cfg.Server.Address)

As the number of dependencies grows, this code becomes dramatically more complex. Get the order wrong and you get a compile error, and every time you add a new service you have to modify main().

uber/fx solves this problem. It analyzes the parameters and return types of constructor functions to build the dependency graph automatically, and creates everything in the correct order.

go get go.uber.org/fx

2.2 fx Basic Concepts

Before diving in, here is an at-a-glance summary of the fx methods covered in this article. fx has many methods that are easy to confuse, so use the table below as a map while reading. The order of categories matches the article's flow (basics → extension → testing).

MethodCategoryRoleIntroduced in
fx.NewBasicCreate the app container
fx.ProvideBasiclazy registration (register in the graph by return type)
fx.InvokeBasiceager execution (side effects like starting the server, registering routes)
fx.SupplyBasicregister a value directly without a constructor
fx.LifecycleLifecyclemanage startup/shutdown via OnStart/OnStop hooks
fx.ModuleExtensiongroup dependencies by domainv1.17+
fx.DecorateExtensionwrap an existing dependency (logging/caching/metrics)v1.18+
fx.AnnotateExtensionattach metadata to a constructor (name/group/As)
fx.ResultTags + name:Extensionidentify the same type individually
group: tagExtensioncollect implementations of the same interface into a slice
fx.PrivateExtensionencapsulate a dependency within a Modulev1.20+
fxtest.NewTestingcreate a test-only app
fx.ReplaceTestingreplace an existing Provide with a Mock
fx.PopulateTestingextract an internal container instance into an external variable

The following sections cover each method one by one with hands-on examples. First, let's look at the most basic building blocks: fx.Provide, fx.Invoke, fx.Supply, and fx.New.

A constructor registered with fx.Provide() is not executed immediately. It is created lazily when the corresponding type is needed elsewhere.

// fx_test.go
func TestFx_Provide_Invoke(t *testing.T) {
    var svc *UserService

    app := fxtest.New(t,
        // fx.Provide: only registers the constructor. It is not run immediately; it is called lazily when needed as a dependency.
        fx.Provide(
            NewLogger,       // returns the Logger interface
            NewMysqlUserRepo, // returns the UserRepository interface (needs Logger)
            NewUserService,   // returns *UserService (needs UserRepository)
        ),
        // fx.Invoke: a side effect run immediately at app startup. Here it pulls the assembled UserService into an external variable.
        fx.Invoke(func(s *UserService) {
            svc = s
        }),
    )
    defer app.RequireStop()
    app.RequireStart()

    assert.Equal(t, "user-1", svc.repo.FindByID(1))
}

fx.Supply() provides an already-built value directly, without a constructor.

// fx_test.go
type Config struct {
    DBHost string
    DBPort int
}

cfg := &Config{DBHost: "localhost", DBPort: 3306}

app := fxtest.New(t,
    fx.Supply(cfg), // register the value directly without a constructor
    fx.Invoke(func(c *Config) {
        // c.DBHost == "localhost"
    }),
)

2.3 Applying fx to a Real Project

Converting the manual DI code to fx looks like this.

// cmd/main.go
app := fx.New(
    fx.Provide(
        config.New,              // *config.Config
        database.New,            // *sql.DB (needs *config.Config)
        NewEcho,                 // *echo.Echo
        ProvideBasicConfig,      // time.Duration

        article.NewArticleHandler,         // Handler (needs Echo, UseCase)
        article.NewArticleUsecase,         // UseCase (needs Repo, AuthorRepo, Duration)
        article.NewMysqlArticleRepository, // Repository (needs DB)

        author.NewMysqlAuthorRepository,   // AuthorRepo (needs DB)
    ),
    fx.Invoke(registerHooks),  // start the server
)

fx looks at each constructor's parameter types to determine the dependency order automatically. For example, database.New(cfg *config.Config) needs a *config.Config, so config.New() is called first.

Looking at each constructor's signature, the dependency relationships are clear.

// pkg/config/config.go
func New() (*config.Config, error) { ... }

// pkg/database/db.go
func New(cfg *config.Config) (*sql.DB, error) { ... }

// article/usecase.go
func NewArticleUsecase(
    a domain.ArticleRepository,
    ar domain.AuthorRepository,
    timeout time.Duration,
) domain.ArticleUsecase { ... }

// article/handler.go
func NewArticleHandler(e *echo.Echo, us domain.ArticleUsecase) *ArticleHandler { ... }

fx.Provide() accepts not only predefined constructors like the ones above but also anonymous functions directly. For simple conversion logic, it's convenient to handle it inline rather than creating a separate constructor file. Expanding the ProvideBasicConfig above into an anonymous function looks like this.

// cmd/main.go
app := fx.New(
    fx.Provide(
        config.New,
        database.New,
        NewEcho,

        // an anonymous function can be registered as a constructor too — only the parameters and return type need to match
        func(cfg *config.Config) time.Duration {
            return time.Duration(cfg.Context.Timeout) * time.Second
        },

        article.NewArticleHandler,
        article.NewArticleUsecase,
        article.NewMysqlArticleRepository,
        author.NewMysqlAuthorRepository,
    ),
    fx.Invoke(registerHooks),
)

Just like a named constructor, fx analyzes the anonymous function's parameters (*config.Config) and return type (time.Duration) and wires it into the dependency graph. Only the form of the function differs; from fx's perspective it's the same constructor.

Visualizing the dependency graph that fx builds from the constructors registered this way looks like this.

graph TD
    Config["config.New()"] --> Database["database.New()"]
    Database --> ArticleRepo["NewMysqlArticleRepository()"]
    Database --> AuthorRepo["NewMysqlAuthorRepository()"]
    ArticleRepo --> ArticleUsecase["NewArticleUsecase()"]
    AuthorRepo --> ArticleUsecase
    ArticleUsecase --> ArticleHandler["NewArticleHandler()"]
    Config --> Echo["NewEcho()"]
    Config --> RegisterHooks["registerHooks()"]
    Echo --> RegisterHooks
    ArticleHandler --> RegisterHooks

fx builds this graph automatically using only the constructors' parameters and return types. If there is a circular dependency, it prints a clear error message at app startup.

2.4 Lifecycle Management

fx.Lifecycle manages app startup and shutdown. You start the server in OnStart and handle graceful shutdown in OnStop.

// cmd/main.go
func registerHooks(lifecycle fx.Lifecycle, e *echo.Echo, cfg *config.Config) {
    lifecycle.Append(
        fx.Hook{
            OnStart: func(context.Context) error {
                fmt.Println("Starting server")
                go e.Start(cfg.Server.Address)
                return nil
            },
            OnStop: func(context.Context) error {
                fmt.Println("Stopping server")
                return nil
            },
        },
    )
}

registerHooks is registered with fx.Invoke(). One thing to watch out for is the timing of execution. fx.Invoke (and fx.Populate, which uses it internally) runs immediately at the time fx.New() is called, not at app.Start(). So the registerHooks above is called during the fx.New() step and only registers the OnStart/OnStop hooks on the Lifecycle; the actual server boot (the body of OnStart) is triggered later at app.Start(ctx). This is why fx.Lifecycle has this two-phase structure — register the hooks early via Invoke, and defer running the hook bodies until Start.

PhaseTimingWhat runs
1fx.New() / fxtest.New()the body of the fx.Invoke function (= fx.Populate is also filled here), registration of Lifecycle hooks
2app.Start(ctx)the registered OnStart hooks run (server boot, etc.)
3app.Stop(ctx)the registered OnStop hooks run (graceful shutdown)
// fx_test.go
func TestFx_Lifecycle(t *testing.T) {
    var startCalled, stopCalled bool

    app := fxtest.New(t,
        fx.Invoke(func(lc fx.Lifecycle) {
            lc.Append(fx.Hook{
                OnStart: func(context.Context) error {
                    startCalled = true
                    return nil
                },
                OnStop: func(context.Context) error {
                    stopCalled = true
                    return nil
                },
            })
        }),
    )

    app.RequireStart()
    assert.True(t, startCalled)

    app.RequireStop()
    assert.True(t, stopCalled)
}

3. Extension Patterns

From fx.Module to fx.Private, let's look at the extension tools that build on top of the fx basics.

3.1 The fx.Module Pattern

fx.Module() groups related dependencies by domain. As the app grows, listing all constructors together in fx.Provide() hurts readability. Splitting them into Modules makes concerns clearer.

// fx_test.go
var UserModule = fx.Module("user",
    fx.Provide(
        NewMysqlUserRepo,
        NewUserService,
    ),
)

var OrderModule = fx.Module("order",
    fx.Provide(
        NewMysqlOrderRepo,
        NewOrderService,
    ),
)

app := fxtest.New(t,
    fx.Provide(NewLogger), // shared dependency
    UserModule,
    OrderModule,
    fx.Invoke(func(u *UserService, o *OrderService) {
        // all dependencies are wired automatically
    }),
)

In real projects, a common pattern is to define a Module variable in each domain package and compose them in main().

// real-world application example
app := fx.New(
    fx.Provide(config.New, database.New, NewEcho),
    article.Module,   // article domain module
    author.Module,    // author domain module
    payment.Module,   // payment domain module
    fx.Invoke(registerHooks),
)

fx.Module is available from v1.17.0+. In earlier versions, you can do similar grouping with fx.Options(), but it does not support module names or scope isolation.

3.2 The fx.Decorate Pattern

fx.Decorate() wraps an existing dependency to add behavior. It's the same concept as the decorator pattern, used for logging, caching, metrics collection, and so on.

// fx_test.go
// logging decorator: wraps the existing UserRepository
type loggingUserRepo struct {
    inner  UserRepository
    logger Logger
    calls  []string
}

func (r *loggingUserRepo) FindByID(id int) string {
    r.calls = append(r.calls, fmt.Sprintf("FindByID(%d)", id))
    return r.inner.FindByID(id) // call the original
}

app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    // replace the existing UserRepository with a logging wrapper
    fx.Decorate(func(repo UserRepository, logger Logger) UserRepository {
        return &loggingUserRepo{inner: repo, logger: logger}
    }),
    fx.Invoke(func(svc *UserService) {
        svc.repo.FindByID(1) // called through the logging wrapper
    }),
)

fx.Decorate() takes the original dependency as a parameter and returns a new wrapped instance. UserService receives the wrapped Repository automatically, with no changes.

fx.Decorate is available from v1.18.0+.

3.3 fx.Annotate + Named Dependencies

When you need to distinguish multiple instances of the same type, use fx.Annotate() and the name tag. A typical case is separating Read/Write DBs.

// fx_test.go
type DBConnection struct {
    Name string
    DSN  string
}

func NewReadDB() *DBConnection {
    return &DBConnection{Name: "read", DSN: "read-replica:3306"}
}

func NewWriteDB() *DBConnection {
    return &DBConnection{Name: "write", DSN: "primary:3306"}
}

Use fx.Annotate() to give each constructor a name.

// fx_test.go
fx.Provide(
    fx.Annotate(NewReadDB, fx.ResultTags(`name:"readDB"`)),
    fx.Annotate(NewWriteDB, fx.ResultTags(`name:"writeDB"`)),
    NewDBService,
)

On the receiving side, match by name tag in an fx.In struct.

// fx_test.go
type DBParams struct {
    fx.In
    ReadDB  *DBConnection `name:"readDB"`
    WriteDB *DBConnection `name:"writeDB"`
}

func NewDBService(params DBParams) *DBService {
    return &DBService{
        readDB:  params.ReadDB,  // read-replica:3306
        writeDB: params.WriteDB, // primary:3306
    }
}

3.4 Collecting Multiple Implementations of the Same Interface with fx.Group

The name: tag is for identifying the same type individually. But if you want to inject multiple implementations of the same interface all at once — for example, sending a notification to every Notifier — name: is not enough, because you'd have to give each implementation a different name and receive them one by one on the receiving side.

The group: tag solves this problem. Implementations registered in the same group are injected together as a slice.

// fx_test.go
type Notifier interface {
    Send(msg string) string
}

type EmailNotifier struct{}
func (e *EmailNotifier) Send(msg string) string { return "email:" + msg }

type SlackNotifier struct{}
func (s *SlackNotifier) Send(msg string) string { return "slack:" + msg }

type SMSNotifier struct{}
func (s *SMSNotifier) Send(msg string) string { return "sms:" + msg }

Use fx.Annotate() and fx.ResultTags() to register each constructor in the same group.

// fx_test.go
fx.Provide(
    fx.Annotate(func() Notifier { return &EmailNotifier{} },
        fx.ResultTags(`group:"notifiers"`)),
    fx.Annotate(func() Notifier { return &SlackNotifier{} },
        fx.ResultTags(`group:"notifiers"`)),
    fx.Annotate(func() Notifier { return &SMSNotifier{} },
        fx.ResultTags(`group:"notifiers"`)),
    NewNotifierService,
)

The receiving side takes it as a slice field with a group: tag in an fx.In struct.

// fx_test.go
type NotifierParams struct {
    fx.In
    Notifiers []Notifier `group:"notifiers"`
}

type NotifierService struct {
    notifiers []Notifier
}

func NewNotifierService(p NotifierParams) *NotifierService {
    return &NotifierService{notifiers: p.Notifiers}
}

A representative real-world use is collecting multiple external service clients into a single interface slice. Even when a new implementation is added, the receiving-side code does not change.

To summarize the difference between name: and group::

PatternUseReceiving side
name:"X"identify the same type individuallysingle field
group:"Y"collect the same type (or interface)slice field

3.5 Encapsulating a Module with fx.Private

Even if you split domains with fx.Module(), every fx.Provide() is exposed globally by default. For a dependency you want to use only inside a Module, you can block exposure with fx.Private. It's useful for preventing another Module from accidentally sharing the same instance of an infrastructure dependency such as a database handle or external API client.

When you place fx.Private inside the same fx.Provide() call alongside other constructors, it makes that entire group Module-private.

// fx_test.go
type internalDB struct {
    name string
}

func newInternalDB() *internalDB {
    return &internalDB{name: "private-db"}
}

type ModuleService struct {
    db *internalDB
}

func newModuleService(db *internalDB) *ModuleService {
    return &ModuleService{db: db}
}

PrivateModule := fx.Module("private",
    fx.Provide(
        newInternalDB,
        fx.Private,        // makes the entire fx.Provide() group Module-internal
    ),
    fx.Provide(newModuleService), // ModuleService is exposed externally
)

*internalDB can only be injected into newModuleService inside PrivateModule. If you request *internalDB directly from outside the Module, fx returns an error when constructing the dependency graph (fx.Populate is covered in §2.8.3).

// fx_test.go
// attempting to extract *internalDB directly from outside → fx.New returns an error
var leaked *internalDB
leakApp := fx.New(
    PrivateModule,
    fx.Populate(&leaked),
    fx.NopLogger,
)
// leakApp.Err() != nil

fx.Private is available from v1.20.0+. In earlier versions, even with fx.Module isolation, all Provides are registered in the global graph.

4. Testing Strategies

An app built with fx is tested with the fxtest package. We'll also look at mock injection and instance extraction.

4.1 fxtest.New

fxtest.New() creates a test-only app. It cleans up automatically on test failure, and fx logs are included in the test output.

// fx_test.go
app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Invoke(func(svc *UserService) { ... }),
)
defer app.RequireStop()
app.RequireStart()

4.2 Injecting a Mock with fx.Replace

fx.Replace() completely replaces an existing Provide. It's useful for injecting a Mock instead of the real implementation in a test.

// fx_test.go
type mockUserRepo struct{}

func (r *mockUserRepo) FindByID(id int) string {
    return fmt.Sprintf("mock-user-%d", id) // Mock response
}

app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    // replace the real UserRepository with a Mock
    fx.Replace(fx.Annotate(&mockUserRepo{}, fx.As(new(UserRepository)))),
    fx.Invoke(func(svc *UserService) {
        result := svc.repo.FindByID(1)
        // result == "mock-user-1"
    }),
)

fx.As(new(UserRepository)) registers *mockUserRepo after type-converting it to the UserRepository interface.

4.3 Extracting an Instance with fx.Populate

So far we've captured instances into external variables in the fx.Invoke(func(s *Svc) { svc = s }) form. fx.Populate does the same thing more concisely.

In fact, fx.Populate is a convenience function implemented internally with fx.Invoke. fx.Populate(&svc) is equivalent to automatically generating "an fx.Invoke that assigns the injected value to svc." In other words, the two are essentially the same. The difference is in purpose. fx.Invoke exists to do something with the pulled-out dependency, so you can call, verify, or do anything in the closure body (extraction is just one of those things); fx.Populate exists for extraction itself, so it omits the closure when it would just be boilerplate.

Comparing the two methods:

Aspectfx.Invokefx.Populate
Essenceruns a functionfills a variable
What you passa function (closure)a pointer
Bodyyes — assign, call, verify, anythingnone — extraction only
Extractionpossible as a side effect via svc = s in the closurethat is its sole purpose
// fx_test.go
// Approach 1: capture via an fx.Invoke closure (the approach used earlier)
var svc1 *UserService
app1 := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Invoke(func(s *UserService) {
        svc1 = s
    }),
)

// Approach 2: extract directly with fx.Populate
var svc2 *UserService
app2 := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Populate(&svc2),
)

The difference is even more pronounced when extracting multiple instances at once.

// fx_test.go
var (
    svc    *UserService
    logger Logger
)
app := fxtest.New(t,
    fx.Provide(NewLogger, NewMysqlUserRepo, NewUserService),
    fx.Populate(&svc, &logger),
)

The selection guide is simple.

SituationRecommended
The goal is to pull an instance into an external variablefx.Populate
Perform a function call or additional verification at the same point after extractionfx.Invoke

5. Wrapping Up

fx has many methods and is easy to confuse. Here's a summary of what to choose in each situation.

What you want to doMethodTip for choosing
Register a dependency in the graph (created lazily later)fx.Providenot immediate — called when needed
Run immediately at app startup (server boot, route registration)fx.Invokeif confused with Provide, "if it's a side effect, use Invoke"
Inject an already-built value/config without a constructorfx.Supplysuitable for constants and config values
Manage startup/shutdown hooks (graceful shutdown)fx.Lifecycleregister with Append inside Invoke
Group dependencies by domainfx.Modulesplit a grown Provide list
Add logging/caching to an existing dependencyfx.Decoratewrap without modifying the original code
Identify the same type individually (read/write DB)fx.Annotate + name:receiving side is a single field
Inject implementations of the same interface collected togethergroup: tagreceiving side is a slice field
Hide a Module's internal dependency from the outsidefx.Privateisolate infrastructure handles
Use a Mock instead of the real implementation in testsfx.Replacematch the interface with fx.As
Pull a container's internal instance out in testsfx.Populateif verifying at the same time, use fx.Invoke

Remember just one thing. You don't need to manage every dependency with fx. Simple value objects and utilities are clearer to create directly, and fx shines most when you focus it on components that need lifecycle management (DB connections, HTTP servers, external clients). Because it's reflection-based, you give up some compile-time type safety, but its detailed runtime error messages and real-world productivity more than make up for it.

The full source is available in the following two places.

6. References

관련 글