The wiring problem
You are building a microservice. It needs a database pool, a Redis cache, an HTTP router, and a background worker. Each component depends on the others. You start writing main.go and suddenly you are managing initialization order, error handling, and graceful shutdown all in one file. You change the constructor signature for the cache and break the router. You add a new background job and realize the database connection needs to be passed through three layers of structs. The file grows into a tangled initialization script. You spend more time managing dependencies than writing business logic.
That is the wiring problem. Go does not have built-in dependency injection. The language prefers explicit function parameters and simple composition. uber/fx bridges that gap by reading your function signatures and building the dependency graph for you. It removes the manual plumbing while keeping Go's explicit type system intact.
Dependency injection without the boilerplate
Dependency injection is just a fancy name for passing dependencies to functions that need them instead of letting those functions create them. fx automates this by treating every constructor function as a node in a directed graph. You tell fx what values you can create with fx.Provide. You tell fx what values you need to run with fx.Invoke. The library traces the parameter types, orders the calls correctly, caches singletons, and hands you a fully wired application.
Think of it like a restaurant kitchen. The line cook does not farm the vegetables, age the meat, or bake the bread. The prep team handles sourcing and preparation. The cook just calls for diced onions or seared scallops. fx is the prep team. It reads the recipe cards (your function signatures), knows exactly what each station needs, and delivers the ingredients in the right order. The cook focuses on cooking. You focus on business logic.
fx does not use reflection to mutate structs. It does not generate hidden boilerplate. It relies entirely on Go's type system and function parameters. If a function needs a dependency, it must declare it as a parameter. That constraint keeps the graph explicit and testable.
fx respects Go conventions. It expects context.Context as the first parameter in long-running functions. It expects constructors to return errors on failure. It expects you to run gofmt on every file. The library does not fight the language. It extends it.
A minimal fx application
Here is the smallest working fx application: a logger that feeds a handler, wired entirely through function signatures.
package main
import (
"log"
"net/http"
"go.uber.org/fx"
)
// NewLogger creates a standard logger.
func NewLogger() *log.Logger {
// fx calls this once and caches the result for all consumers
return log.New(log.Writer(), "app: ", log.LstdFlags)
}
// Handler depends on the logger.
type Handler struct {
Logger *log.Logger
}
// NewHandler wires the logger into the struct.
func NewHandler(l *log.Logger) *Handler {
// fx matches the parameter type to the provided logger
return &Handler{Logger: l}
}
func main() {
// fx.New builds the dependency graph and starts the app
fx.New(
fx.Provide(NewLogger),
fx.Provide(NewHandler),
fx.Invoke(func(h *Handler) {
// fx calls this last, after all dependencies are ready
h.Logger.Println("handler wired successfully")
http.ListenAndServe(":8080", h)
}),
).Run()
}
The code reads like a declaration of intent. You provide constructors. You invoke the entry point. fx handles the rest. The Run() call blocks until the application receives a termination signal or panics. Graceful shutdown is built into the lifecycle.
fx caches every provided value by default. NewLogger runs exactly once. NewHandler runs exactly once. If you need multiple instances of the same type, you must return different types or use fx.Annotate to tag them. The default behavior matches how most Go applications expect singletons to behave.
How fx resolves the graph
When fx.New executes, it does not run your constructors immediately. It first scans every fx.Provide call and registers the return types in a type registry. It then scans every fx.Invoke call and records the required parameter types. The library builds a directed acyclic graph where edges represent dependencies.
Resolution happens in reverse topological order. If Invoke needs *Handler, fx traces *Handler back to NewHandler. It sees NewHandler needs *log.Logger. It traces that back to NewLogger. It executes NewLogger, caches the result, executes NewHandler, caches that result, and finally calls the Invoke function. If any constructor returns an error, fx aborts the entire startup sequence. The application never reaches Run().
This fail-fast behavior is intentional. Go developers prefer explicit error handling over silent failures. The community accepts the verbose if err != nil { return err } pattern because it makes the unhappy path visible. fx enforces that discipline at startup. You cannot hide initialization failures behind deferred recovery or ignored errors.
The graph validation happens at runtime, not compile time. This means wiring mistakes will not surface until you execute the binary. If you rename a type or change a constructor signature, the compiler will catch type mismatches inside the functions. It will not catch missing providers. You must run the application or write integration tests that instantiate fx.New to verify the graph.
fx does not perform implicit type conversions. If you provide a concrete *PostgresClient but your handler expects a DBInterface, the graph will fail. You must write an explicit adapter function that returns the interface. Go's type system stays strict. fx respects that boundary.
Realistic setup with modules and lifecycle
Production applications need graceful shutdown, modular boundaries, and structured logging. fx handles these requirements through modules and lifecycle hooks. Here is how a realistic service groups related constructors and manages startup and shutdown events.
package main
import (
"context"
"log"
"net/http"
"go.uber.org/fx"
)
// DBPool represents a database connection pool.
type DBPool struct{}
// NewDBPool initializes the database connection.
func NewDBPool() *DBPool {
// fx caches this value for the entire application lifetime
log.Println("opening database pool")
return &DBPool{}
}
// APIServer handles HTTP requests.
type APIServer struct {
DB *DBPool
}
// NewAPIServer wires the database into the server.
func NewAPIServer(db *DBPool) *APIServer {
// fx matches the parameter type to the provided pool
return &APIServer{DB: db}
}
func main() {
// fx.Module groups related providers and isolates scope
app := fx.New(
fx.Module("database", fx.Provide(NewDBPool)),
fx.Module("api", fx.Provide(NewAPIServer)),
// fx.Hook runs cleanup when the app receives a signal
fx.Hook(
fx.OnStart(func(ctx context.Context) {
// runs after all providers are resolved
log.Println("starting application")
}),
fx.OnStop(func(ctx context.Context) {
// runs before the process exits
log.Println("gracefully shutting down")
}),
),
fx.Invoke(func(s *APIServer) {
// fx passes the fully wired server here
http.ListenAndServe(":8080", s)
}),
)
// app.Run() blocks until context cancellation or panic
app.Run()
}
Modules act as namespaces for your dependency graph. They do not change how fx resolves types. They keep your main function readable and let you extract related constructors into separate packages. You can import a module from another package and compose it into your application. The graph remains flat and explicit.
Lifecycle hooks run in a defined order. OnStart callbacks execute after all providers are resolved but before Invoke runs. OnStop callbacks execute in reverse order when the application receives SIGINT or SIGTERM. The context.Context passed to hooks carries the cancellation signal. You can use it to drain HTTP connections, close database pools, or flush metrics. The context plumbing is automatic. You just respect the cancellation deadline.
fx expects you to follow Go's receiver naming convention. Use short names like (s *APIServer) or (p *DBPool). Do not use this or self. The community reads code faster when receiver names match the type initial. Trust gofmt. Argue logic, not formatting.
Pitfalls and compiler friction
fx simplifies wiring but introduces its own failure modes. The most common mistake is forgetting to provide a dependency. If you reference a type in an Invoke function but never register it with Provide, fx aborts with fx: missing dependencies for function ...: missing types: *YourType. The error traces the exact gap in the graph. Fix it by adding the missing constructor or removing the unused parameter.
Circular dependencies are another frequent trap. If A needs B and B needs A, fx stops with fx: dependency cycle detected. The library cannot resolve cycles because it relies on topological sorting. Break the cycle by extracting a shared interface, deferring initialization, or restructuring the data flow. Go favors composition over circular references.
fx matches types exactly. It does not perform implicit conversions or interface satisfaction checks during graph construction. If you provide *ConcreteClient but your consumer expects ClientInterface, the graph fails. You must write an explicit adapter:
func ProvideClientInterface(c *ConcreteClient) ClientInterface {
// explicit conversion satisfies the type system
return c
}
This friction is deliberate. Implicit conversions hide architectural decisions. Explicit adapters make the boundary visible. You will see this pattern in production codebases. Accept it.
Testing fx applications requires swapping providers. You do not need to refactor your production code. You simply pass mock constructors to fx.New in your test suite. The graph resolution works identically. If your mock returns an error, fx aborts the test setup. If your mock returns a stub, the rest of the application runs against controlled data. Write integration tests that instantiate the full graph. Catch wiring mistakes before they reach staging.
The worst fx bug is the one that never logs. If a constructor silently drops an error or returns a nil value, the graph continues. The application crashes later with a nil pointer dereference. Always return errors from constructors. Never ignore them. The compiler will not save you here. Your test suite will.
When to reach for fx
Use uber/fx when your application has more than five interdependent services and manual initialization order becomes error-prone. Use manual wiring in main when the dependency graph is flat and changes rarely. Use a lightweight DI library like wire when you want compile-time graph generation and zero runtime overhead. Use plain function parameters when you are writing a library or a small CLI tool that boots in milliseconds.
fx adds a small runtime cost to graph construction. That cost is negligible compared to database connections and TLS handshakes. The trade-off is readability and maintainability. You gain explicit dependency tracking, automatic lifecycle management, and straightforward testing. You lose the ability to hide initialization logic behind global variables or hidden constructors. That loss is a feature.
fx does not replace good architecture. It amplifies it. If your constructors are doing too much work, fx will not save you. Keep constructors focused. Return errors early. Pass context through long-running calls. The library handles the wiring. You handle the design.