iTranslated by AI
A Practical Guide to DDD with uber-fx in Go: Simplifying Complex Dependency Management
This is the article for Day 16 of the Go Advent Calendar 2025.
Summary in 3 Points
- We resolve the bloated
main.goproblem in DDD implementations of Go using the DI libraryuber-go/fx(hereafter "fx"). - We explain how to use
fx.Moduleandfx.Privateto wire layers in a loosely coupled manner while maintaining context boundaries. - We also introduce strategies for graceful shutdown via lifecycle management and dependency replacement during testing using
fx.Replace.

1. Introduction: Why is a DI library necessary for DDD implementation?
Adopting Domain-Driven Design (DDD) or Clean Architecture in Go helps organize dependencies between layers and improves testability. However, once you exceed 20 to 30 structs, the initialization code in main.go grows rapidly, leading to fragile points such as "having to reorder initialization every time a dependency is added" or "having to touch main.go every time a mock is replaced in a test."
In this article, I will introduce practical methods to cleanly separate and wire DDD layer structures without cluttering Go struct definitions, while maintaining testability using the DI library uber-go/fx.
The official repository for the uber-go/fx library can be found here.
All code snippets in this article are excerpts from the following repository.
Sample code for the implementation is available in the following repository (see under internal/).
2. Why uber-fx: Comparison with manual DI
To clarify why we adopt a DI library, specifically uber-fx, in DDD implementation, let's first compare it to cases where no library is used.
Challenges of manual DI
Some argue that in Go, "it is simple and good to assemble manually in the main function without using a DI container." For small-scale applications, that is often the optimal solution.
However, as layers become multi-layered and the number of components increases—as in DDD—main.go becomes a clump of initialization boilerplate like the following:
// Example of manual DI (becomes difficult to manage as the project grows)
func main() {
// 1. Infrastructure layer initialization
// Initialization considering dependency order is required
dbConn := db.NewDatabase()
// Logger is needed in many places, so it is created at an early stage
logger := logger.NewLogger()
// You need to pass the Logger and DB around like a bucket brigade
userRepo := db.NewUserRepositoryImpl(logger, dbConn)
taskRepo := db.NewTaskRepositoryImpl(logger, dbConn)
// 2. Application layer initialization
// If the Service layer also needs a Logger, you must pass it again
userService := service.NewUserServiceImpl(logger, userRepo)
taskService := service.NewTaskServiceImpl(logger, taskRepo)
// 3. Presentation layer initialization
// Every time an argument is added, you need to modify this part
userHandler := server.NewUserServiceHandler(logger, userService)
taskHandler := server.NewTaskServiceHandler(logger, taskService)
// 4. Server startup
srv := server.NewServiceServer(userHandler, taskHandler)
srv.Run()
}
This approach leads to the following problems as the operational scale grows. In particular, the point that "you have to review both the initialization order and test replacement just by adding a single dependency" tends to become a fragile point.
- Dependency order management: You must continuously solve the dependency puzzle of "I need B to create A, so I must create B first...". Adding a single dependency causes the initialization order to be reordered.
- Passing common components: You need to pass components used across the app, such as Logger, Config, and Tracer, all the way to the end-point Repositories, requiring a bucket brigade through all intermediate layer constructors. You must follow the same path when swapping them out for tests.
-
Modification costs: Adding just one dependency to a Service requires reviewing the entire initialization flow in
main.go. As a result,main.gotends to become a "fragile point."
What happens when you introduce uber-fx
When you introduce fx, the procedural code above changes into a style where you simply declare "what to use (dependency definition)." Using fx.Invoke, which I will detail later, allows you to declaratively register processes you definitely want to call at startup (e.g., server startup).
// Example of uber-fx
func main() {
fx.New(
// Just "register" the constructors. No need to worry about the order
fx.Provide(
db.NewDatabase,
logger.NewLogger, // Just register the Logger too
db.NewUserRepositoryImpl, // fx automatically resolves and injects arguments (*Database, Logger)
service.NewUserServiceImpl, // fx automatically resolves and injects arguments (Repo, Logger)
server.NewUserServiceHandler,
),
// Entry point at startup
fx.Invoke(func(srv *server.ServiceServer) {
srv.Run()
}),
).Run()
}
fx.Provide automatically resolves and injects necessary instances from the constructor argument types. Since the library builds the dependency graph, there is no need to manually manage the initialization order. I will discuss processes at startup (functions registered with fx.Invoke) later.
3 Benefits of choosing uber-fx
- Automatic dependency resolution: It infers the necessary instances from the constructor argument types and injects them automatically.
-
Modularization: You can split DI settings into
fx.Modulefor each feature or layer, making it easier to represent context boundaries in code. - Unified lifecycle management: With hook features at application start (OnStart) and end (OnStop), you can achieve Graceful Shutdown and resource cleanup under DI management.
In this article, I will explain how to use uber-fx to keep each layer of DDD loosely coupled while wiring them efficiently.
3. Assumptions about Architecture and Directory Structure
Before diving into the specific implementation, let me share the layer structure of the application covered in this article. uber-fx demonstrates its true value when you define fx.Module according to your project's directory structure.
In this article, I assume a standard Go DDD project structure for TODO task management, as follows. Dependencies flow in one direction from the outside to the inside: Presentation -> Application -> Domain <- Infrastructure. The Infrastructure layer implements the interfaces defined in the Domain layer and is injected into upper layers by the DI container.
internal/
├── domain/ # Entities, repository interfaces
├── application/ # Application services, DTOs
├── infrastructure/ # Repository implementations, DB, Logger
└── presentation/ # HTTP handlers, server settings
The relationship diagram of each layer is as follows.
4. How uber-fx works
Before going into practice, let me touch upon how uber-fx works.
Simply passing functions to fx.Provide resolves dependencies automatically, but under the hood, the process goes through these steps:
Step 1: Type Information Analysis (Reflection)
It analyzes the "argument types" and "return types" of the constructor functions passed to fx.Provide.
func NewUserService(repo UserRepository) *UserService { ... }
When this function is registered, uber-fx recognizes it as a definition that "takes UserRepository as input and outputs *UserService."
Step 2: Building the Dependency Graph
Once fx.New(...) is called, it builds a dependency graph based on all registered constructor information.
-
*UserServicerequiresUserRepository -
UserRepositoryis provided byNewUserRepository -
NewUserRepositoryrequires*Database
It constructs dependencies like assembling a puzzle. If a required type is missing or there is a circular reference, an error is reported before startup.
Step 3: Topological Sort and Lazy Initialization
Once the dependency graph is built, uber-fx determines the order to call the constructors based on the dependency relationships.
Regardless of the order written in fx.Provide, they are executed in the correct order such as "Infrastructure -> Application -> Presentation" based on dependencies.
Step 4: Lifecycle Execution
When the Run() method is called, the following lifecycle is executed. I will show an example of Graceful Shutdown using OnStart and OnStop at the end.
- Initialization: Sequentially executes constructors according to the dependency graph order to generate and inject instances.
-
OnStart: Executes registered
OnStarthooks (such as starting server Listen) once all initialization is complete. - Wait: Waits for OS signals (like Ctrl+C).
-
OnStop: Upon receiving a signal, it executes
OnStophooks (DB disconnection, server stop, etc.) in reverse order ofOnStartto terminate safely.
Let's look at concrete DDD implementation patterns from here.
5. Practice 1: Bundling by Boundaries with fx.Module
One very useful feature in DDD implementation is fx.Module. Using this, you can group DI settings by feature or layer.
For example, consider the case where the infrastructure package provides "DB connection" and "Repository implementation."
// internal/infrastructure/module.go (Step 1: Basic form)
var Module = fx.Module(
"infrastructure",
fx.Provide(
// Register constructor functions
db.NewDatabase, // func NewDatabase() *Database
db.NewUserRepositoryImpl, // func NewUserRepositoryImpl(db *Database) *UserRepositoryImpl
),
)
By simply loading this in main.go, you can synthesize by module units.
// cmd/server/main.go
func main() {
app := fx.New(
infrastructure.Module, // Infrastructure layer
application.Module, // App layer
presentation.Module, // Presentation layer
)
app.Run()
}
What is important here is that dependencies between modules can also be expressed in code.
In this case, it is a hierarchical structure where presentation uses application, and application uses infrastructure. We reflect this in the module definition.
// internal/presentation/module.go
var Module = fx.Module(
"presentation",
application.Module, // Import application layer (does not know infrastructure layer directly)
fx.Provide(
server.NewUserServiceHandler,
),
)
// internal/application/module.go
var Module = fx.Module(
"application",
infrastructure.Module, // Import infrastructure layer
fx.Provide(
service.NewUserServiceImpl,
),
)
By doing this, upper layers wrap lower layer modules, keeping main.go even simpler. For instance, in this setup, the presentation module imports the application module, and the application module imports the infrastructure module. Consequently, you only need to load the presentation module in main.go.
- Module hierarchy image
-
presentation.Module(Top level)-
application.Moduleinfrastructure.Module
-
-
Creating a structure where "upper layers contain lower layers" as shown above allows you to explicitly define dependency and assembly order in code, helping to prevent main.go from becoming fragile.
6. Practice 2: Injecting Interfaces with fx.Annotate
As seen in Practice 1, simply using fx.Provide can lead to problems with the principles of DDD dependency relationships. The key point here is "registering it as an interface while keeping the constructor returning a struct."
NewUserRepositoryImpl returns a concrete struct (*UserRepositoryImpl), so registering it as-is would cause the Application layer to depend directly on the concrete type in the Infrastructure layer. This violates the Dependency Inversion Principle (DIP).
Ideally, the Application layer should depend only on the interface (domain.UserRepository) defined in the Domain layer.
You might think, "Why not just change the constructor's return value to an interface?" However, Go has a best practice: "Accept interfaces, return structs."
If a constructor returns an interface, implementation details become overly hidden, flexibility is lost, and the caller's freedom to define a "minimal interface with only the required methods" is compromised.
This is where fx.Annotate and fx.As come in.
They allow you to maintain the Go idiom of "constructors return structs" while having them behave as interfaces only when registered in the DI container.
// internal/infrastructure/module.go (Step 2: Registering as an interface)
var Module = fx.Module(
"infrastructure",
fx.Provide(
db.NewDatabase,
// Register a constructor that returns a "struct" as a Provider for an "interface"
fx.Annotate(
db.NewUserRepositoryImpl,
fx.As(new(domain.UserRepository)),
),
),
)
This allows you to achieve clean Dependency Inversion (DIP) without cluttering your implementation code.
7. Practice 3: Closing Dependencies within Modules Using fx.Private
Let's take it a step further. The DB instance generated by db.NewDatabase is only needed within the Infrastructure layer (for repository implementations) and should not be accessed directly by the Domain or Application layers.
By using fx.Private, you can restrict dependencies to be available only within their module, preventing external exposure.
// internal/infrastructure/module.go (Step 3: Encapsulation)
var Module = fx.Module(
"infrastructure",
fx.Provide(
// Components to expose
fx.Annotate(
db.NewUserRepositoryImpl,
fx.As(new(domain.UserRepository)),
),
),
// Components used only internally
fx.Provide(
db.NewDatabase,
fx.Private, // <--- Becomes invisible outside this module
),
)
This allows you to prevent architectural violations—such as accidentally accessing a raw DB connection within domain logic—at startup time. Specifically, if you register a component in fx.Provide that takes the return value of db.NewDatabase as an argument from the Application layer, the application will fail to start because the dependency cannot be found.
8. Practice 4: Injecting Configuration Values with fx.Supply and Tags
So far, we have discussed dependency resolution for structs. But how should we handle "primitive values" like port numbers or log levels?
This is where fx.Supply and tags come into play.
Providing Side (main.go)
Use fx.Supply and fx.Annotated to register tagged values into the container.
// cmd/server/main.go
func main() {
app := fx.New(
fx.Supply(
// Register the value "8080" with the tag "serverPort"
fx.Annotated{Name: "serverPort", Target: "8080"},
),
presentation.Module,
// ...
)
app.Run()
}
Consuming Side (Module)
In the constructor on the receiving side, use fx.ParamTags to specify which tagged value is required.
// internal/presentation/module.go
// Example signature for server.NewServiceServerConfig:
// func NewServiceServerConfig(port string) (*ServerConfig, error) { ... }
fx.Provide(
fx.Annotate(
server.NewServiceServerConfig,
// Inject the value tagged with "serverPort" into the first argument (port)
fx.ParamTags(`name:"serverPort"`),
),
),
By doing this, you can inject only the necessary configuration values pinpoint-style, without passing around massive Config structs.
9. Practice 5: Achieving OCP (Open-Closed Principle) with Grouping
Next, I will introduce the highly extensible Group feature. This pattern addresses the requirement: "I want to avoid modifying existing code when adding new features."
Before: Without the Group Feature
Without the Group feature, every time a handler is added, you must add a field to the ServiceServer struct and also increase the number of arguments in its constructor.
// ❌ Bad example: You have to modify this every time a new Handler is added
type ServiceServer struct {
userHandler *UserHandler
taskHandler *TaskHandler
// productHandler *ProductHandler <-- Adding this
}
// The constructor also needs modifications...
func NewServiceServer(u *UserHandler, t *TaskHandler /*, p *ProductHandler */) *ServiceServer {
// ...
}
This violates the Open-Closed Principle (open for extension, closed for modification), as you are forced to touch the ServiceServer code—which shouldn't need modification—whenever you add a new API endpoint.
After: Using the Group Feature
With the uber-fx Group feature, you can register individual handlers as "members of the same group" and receive them as a "slice" on the consuming side. This means that when adding a new API, you only need to add the Handler in module.go, without having to modify the Server's implementation code.
1. Registration Side (Each Handler)
Register each handler as a member of the group:"routes" group.
// internal/presentation/module.go
fx.Provide(
fx.Annotate(
server.NewUserServiceHandler,
fx.As(new(server.RouteRegistrar)), // Common interface
fx.ResultTags(`group:"routes"`), // Add to "routes" group
),
fx.Annotate(
server.NewTaskServiceHandler,
fx.As(new(server.RouteRegistrar)),
fx.ResultTags(`group:"routes"`), // Add this to "routes" group too
),
// Just add new handlers here! No changes needed on the Server side.
)
2. Consuming Side (Server)
On the server side, there is no need to know about individual handlers. You receive them as a slice collected via group:"routes".
RouteRegistrar is an interface that acts as a common hook for "registering my endpoints to the router."
// internal/presentation/server/server.go
// ✅ Good example: No need to know about specific Handlers
type ServiceServer struct {
routes []RouteRegistrar
}
// Constructor registration part (fx.Annotate)
fx.Annotate(
server.NewServiceServer,
// Receive routes []RouteRegistrar as an argument
// Instructing here to inject the entire "routes" group
fx.ParamTags(`group:"routes"`),
),
10. Practice 6: Lifecycle Management and Graceful Shutdown
Moving slightly away from DDD design, Graceful Shutdown is extremely important for production applications.
Normally, to safely stop an HTTP server in Go, you need to write boilerplate code in main.go such as monitoring OS signals (SIGINT, SIGTERM) using signal.NotifyContext and calling server.Shutdown() when a signal is received.
uber-fx incorporates a mechanism called fx.Lifecycle, allowing you to describe these operations declaratively. When you execute app.Run() in main.go, uber-fx automatically waits for OS signals. Upon receiving a signal, it executes the registered OnStop hooks in reverse order of dependencies (e.g., stopping the server -> closing the DB connection) to ensure a safe shutdown.
This allows you to achieve the same (or even safer) Graceful Shutdown mechanism as writing signal.NotifyContext manually, but with much cleaner code. Developers only need to implement hooks that describe "what should be done upon termination," freeing them from the complexity of signal control.
Registering Lifecycle Hooks
Register the server startup and shutdown logic as an fx.Hook. Here, we define the OnStart and OnStop hooks.
- OnStart: Start the server in a non-blocking manner
- OnStop: Run Graceful Shutdown triggered by the received signal
// internal/presentation/server/server.go
func RegisterLifecycleHooks(lc fx.Lifecycle, server *ServiceServer) {
lc.Append(fx.Hook{
// Executed at app startup
OnStart: func(ctx context.Context) error {
go func() {
// Start server in a non-blocking manner
if err := server.e.Start(":8080"); err != nil && err != http.ErrServerClosed {
// Log error if necessary
}
}()
return nil
},
// Executed at app shutdown (when signal is received)
OnStop: func(ctx context.Context) error {
// Run Graceful Shutdown
return server.e.Shutdown(ctx)
},
})
}
Enabling Hooks via fx.Invoke
The defined RegisterLifecycleHooks function will not execute just by being defined. You must instruct the DI container to call this function at startup using fx.Invoke.
fx.Invoke is used to register functions that "must be executed after dependency resolution is complete."
While you could call it individually in main.go, if you are conscious of the layer structure, it is a standard practice to include fx.Invoke within the definition of the module that provides the functionality (in this case, the Presentation layer).
// internal/presentation/module.go
var Module = fx.Module(
"presentation",
application.Module,
fx.Provide(
// ... (Generation of Handlers, Server, etc.) ...
),
// Invoke the hook registration function as part of the module definition
// Required arguments (fx.Lifecycle, *ServiceServer, etc.) are resolved and injected automatically
fx.Invoke(server.RegisterLifecycleHooks),
)
The function registered in fx.Invoke is executed during the fx.New initialization process (Step 4). By adding hooks to fx.Lifecycle here, OnStart / OnStop will be called during subsequent .Run() or termination processes.
As a result, main.go can remain extremely simple:
// cmd/server/main.go
func main() {
fx.New(
// Simply loading the module containing Invoke automatically enables the hooks
presentation.Module,
).Run()
}
Developers only need to implement hooks describing "what to do at termination" and fx.Invoke them in the appropriate module, freeing them from complex code like signal.Notify.
11. Testing Strategy: "Dependency Substitution" with fx.Replace
In integration or E2E tests, if you want to replace certain components (e.g., loggers or external API clients) with mocks, use fx.Replace.
For example, during testing, you can overwrite an existing Logger definition using fx.Replace to disable the logger. External API clients or message queue publishers can be replaced in the same way.
// internal/infrastructure/module_test.go
func TestModule(t *testing.T) {
app := fxtest.New(t,
infrastructure.Module,
// Overwrite the existing Logger definition with this instance
fx.Replace(
slog.New(slog.NewTextHandler(io.Discard, nil)),
),
// ...
)
// ...
}
fx.Replace is a feature that "replaces an existing Provider with a specific value." You can keep the production Module definition as is while changing behavior only during testing.
12. Conclusion
uber-fx is not just a DI container; it can be called a framework for defining the lifecycle and module structure of Go applications.
By introducing uber-fx, you gain the following benefits in DDD projects:
- For
mainbloat: Bundle hierarchy with modules and declare dependency initialization. - For dependency inversion/encapsulation: Enforce interface injection and prevent leakage with
fx.Annotate/fx.Private. - For extensibility: Achieve "1-line addition, zero modification" with the Group feature and tags.
- For operations: Standardize Graceful Shutdown with
fx.Lifecycle.
I recommend starting with simple DI using fx.Provide and gradually introducing fx.Annotate and fx.Module as needed.
Discussion