How I stopped hand-wiring Go structs like a masochist (and learned to love uber-fx)
It's been exactly six months since I started writing Go in production. And let me tell you, coming from a background heavily rooted in PHP and Java (I can already hear the Gophers sharpening their pitchforks), some of the established "go-norms" feel less like best practices and more like self-inflicted torture.
Don't get me wrong, the language itself is brilliant. Fast, clean concurrency, and it generally gets out of your way. But there is one thing about the Go community that makes me want to pull my hair out: the absolute, dogmatic, borderline allergic reaction to Inversion of Control (IoC) containers.
Look, I'm the guy who built a poor man's WAL on Azure storage and taught services to talk to each other without a message broker. I am all for scrappy, explicit engineering. But manually passing 15 pointers down a chain of constructors? That's not engineering. That's a hazing ritual.
The Cascading Nightmare of "Explicitness"
If you mention an IoC container like uber-fx anywhere near a Go forum, someone will inevitably pop up to tell you: "Explicit is better than implicit! You lose ownership of the instantiation!"
Okay, sure. Ownership is great when you're writing a simple CLI tool. But when you are dealing with real scale, managing cross-cutting concerns across dozens of services, that 400-line main.go stops being "explicit." It just becomes a fragile, cascading nightmare of operational noise.
Imagine you decide to introduce a new infrastructure module. Maybe you need to pass an Azure credential client down to some deep, dark corner of your app. With manual wiring, you're not just writing the logic for the client. You are forced to update twenty different constructor signatures and rewrite dozens of mocked unit tests just to pass that one single dependency through the stack. Everything falls apart because you wanted to add one thing.
With uber-fx, I just declare what a module needs and what it provides. Look at this:
var Module = fx.Module("allowlist",
fx.Provide(NewService),
fx.Provide(NewHandler),
fx.Provide(func(h *Handler) general.HandlerResult {
return general.HandlerResult{Handler: h}
}),
fx.Provide(NewInvalidationReceiver),
)
That's it. The service, its HTTP handler, the handler's contribution to the server's route group, and a broadcast receiver for peer cache invalidation - all declared, all wired automatically. The container resolves the five dependencies NewService needs (config, logger, metrics, storage, broadcaster) without me threading them through by hand. I get my time back to write actual business logic instead of acting as a human dependency resolver.
I organize modules in explicit layers: infrastructure (config, logging, metrics, Azure, DNS), domain (business logic), and service (HTTP servers, storage), and each layer only depends downward. That's actual explicit architecture, not a 200-line main.go that nobody reads.
The RunFunc Pattern
One of the patterns I'm proudest of is the RunFunc kernel. In the "explicit" Go world, graceful shutdown usually means a sprawling mess of defer calls, signal handlers, and manual cleanup scattered to the winds in main.go. I centralized the whole thing:
type RunFunc func(ctx context.Context) error
func Run(ctx context.Context, opts ...fx.Option) error {
var run RunFunc
kernel := fx.New(
InfrastructureModules(),
DomainModules(),
ServiceModules(),
fx.Options(opts...),
fx.Populate(&run),
)
if err := kernel.Err(); err != nil {
return fmt.Errorf("constructing dependency graph: %w", err)
}
if err := kernel.Start(ctx); err != nil {
return fmt.Errorf("initializing application: %w", err)
}
var exitErr error
if err := run(ctx); err != nil {
if !errors.Is(err, context.Canceled) {
exitErr = fmt.Errorf("running application: %w", err)
}
}
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := kernel.Stop(stopCtx); err != nil {
return errors.Join(exitErr, fmt.Errorf("shutting down: %w", err))
}
return exitErr
}
The RunFunc is injected by the caller. The kernel doesn't know or care what it's running - just that there's a function to call and a lifecycle to manage. My serve command injects a RunFunc that starts three HTTP servers concurrently via errgroup. Want to disable broadcast? Yank its module. The kernel doesn't blink.
And because fx runs every OnStop lifecycle hook in reverse order during shutdown, resource cleanup (closing HTTP clients, flushing metrics, draining connections) is actually colocated with the code that created the resource, not bolted onto main.go as an afterthought.
I reuse this exact same Run function across all production services without changing a single line of it.
"But what about the magic?"
Cue the purist outrage.
"Hidden dependencies! Magic graph resolution! Runtime panics!"
I won't pretend IoC containers don't have trade-offs. Yes, debugging a deeply nested graph is slightly more annoying than tracing a linear main.go. But the biggest boogeyman, the idea that you trade compile-time safety for random runtime panics because of a missing dependency, is honestly trivial to fix.
You know how you solve the runtime panic issue? A unit test.
uber-fx ships with a brilliant little tool called fxtest. You just write a dead-simple test to load the dependency graph without even starting the app logic. If there's a missing provider, fxtest fails instantly. Boom. Your CI pipeline catches it just like a compiler would, long before your distroless static images ever get deployed.
func TestRun_ExecutesRunFunc(t *testing.T) {
var called atomic.Bool
ctx, cancel := context.WithCancel(t.Context())
err := app.Run(ctx,
app.WithConfigFilename(""),
fx.Supply(app.RunFunc(func(ctx context.Context) error {
called.Store(true)
cancel()
<-ctx.Done()
return ctx.Err()
})),
)
require.NoError(t, err)
require.True(t, called.Load())
}
This test boots the entire dependency graph: config, logging, metrics, Azure clients, DNS, health checks, storage, broadcast, HTTP servers, and verifies it all resolves correctly. If I add a new module and forget to provide one of its dependencies, this test fails before anything gets merged. That's not "runtime magic." That's CI-enforced correctness.
For unit tests, fx.Decorate lets you surgically override specific dependencies. Swap the real config for test values, or inject a mock storage layer while keeping the real infrastructure wiring intact, all without rebuilding the world manually. When the infrastructure changes, those tests keep working because they aren't hardcoded to the plumbing.
When NOT to use it
I'm not a total zealot. If I'm knocking together a tiny standalone Go binary or a cron job with exactly three dependencies, uber-fx is massive overkill. In those cases, a 30-line main.go is perfect. Stick to manual wiring.
The Bottom Line
Call me a masochist for bringing Java-style patterns into Go, but true masochism is writing the exact same boilerplate over and over again just to appease an unwritten community rule. The Go philosophy of "explicit over implicit" has its place, but it completely breaks down as your application grows.
At a certain scale, a dependency graph stops being something a human should maintain by hand. You are an engineer, not a compiler.
Tools exist to serve us, not the other way around. Stop worrying about the purists, let the container handle the plumbing, and just ship the damn code.
This isn’t about replacing Go’s philosophy - it’s about recognizing where it stops scaling.