š§ Refactoring a Go Web Service with Uber FX: From Monolith to ModularĀ Elegance
Introduction
In modern Go development, maintainability and lifecycle control are essential. This article walks through how we refactored a simple Gin-based web service into a modular, testable, and idiomatic Uber FX application. Weāll cover lifecycle hooks, dependency injection, behavioral testing, and configuration-driven design.
In our previous article, we developed a simple RESTful API using Golang. In this article, we aim to extend that work by integrating Uber FX to facilitate dependency injection more effectively.
š§± Original Structure
The original project used a traditional cmd/server/main.go entry point with hardcoded route registration and server startup logic. While functional, it lacked modularity, lifecycle control, and testability.
š Refactoring with UberĀ FX
1. Modular main.go with FX Composition
We replaced the monolithic startup with a clean FX composition:
func main() {
fx.New(fx.Provide(config.NewConfig, health.New, server.NewGinEngine, server.CreateHTTPServer,),
fx.Invoke(server.RegisterRoutes, server.StartHTTPServer,),
).Run()
}This separates concerns and lets FX manage lifecycle and dependency injection.
š§© fx.Provide: Declaring Dependencies
fx.Provide(...) tells FX how to construct the values your app needs. You pass it constructor functions, and FX will automatically call them in the right order, resolving dependencies.
Example:
fx.Provide( config.NewConfig, // returns *Config server.NewGinEngine, // returns
*gin.Engine)This means:
- FX will call
NewConfig()and store the result. - Then it will call
NewGinEngine()and inject any dependencies it needs (like*Configif required).
Think of itĀ as:
āHereās how to build the parts of my app.ā
š§ fx.Invoke: WiringĀ Behavior
fx.Invoke(...) tells FX what to do with the values it built. You pass it functions that receive dependencies and perform actionsālike registering routes or starting a server.
fx.Invoke( server.RegisterRoutes, server.StartHTTPServer,)This means:
- FX will call
RegisterRoutes(engine, health, config)using the values it built. - Then it will call
StartHTTPServer(lifecycle, server)to hook into startup/shutdown.
Think of itĀ as:
āNow that youāve built everything, hereās what to do with it.ā

2. Lifecycle-Aware ServerĀ Startup
We split server creation and startup into two functions:
func CreateHTTPServer(engine *gin.Engine, cfg *config.Config) *http.Server {
return &http.Server{
Addr:
fmt.Sprintf("%s:%d", cfg.HTTPAddress, cfg.HTTPPort), Handler: engine,
}
}
func StartHTTPServer(lc fx.Lifecycle, srv *http.Server) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
log.Printf("š HTTP server starting on %s", srv.Addr) go func() {
if err := srv.ListenAndServe();
err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
() return nil
}, OnStop: func(ctx context.Context) error {
log.Println("š Shutting down HTTP server...") shutdownCtx, cancel :=
context.WithTimeout(ctx, 5*time.Second) defer cancel() return srv.Shutdown(shutdownCtx)
},
})
}3. Dynamic Route Registration
Routes are registered via an FX Invoke:
func RegisterRoutes(engine *gin.Engine, health *health.Health, cfg *config.Config) {
log.Println("š Registering routes...") engine.GET("/health", health.Handle) // Set Swagger
metadata dynamically docs.SwaggerInfo.Title = "My API" docs.SwaggerInfo.Version = "1.0"
docs.SwaggerInfo.Description = "This is a sample API with Gin and Swagger."
docs.SwaggerInfo.Host = fmt.Sprintf("%s:%d", cfg.HTTPAddress, cfg.HTTPPort)
docs.SwaggerInfo.BasePath = "/" docs.SwaggerInfo.Schemes = []string{
"http"
}
// or {
"https"
}
in production engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}4. Config-Driven Port andĀ Address
We introduced a Config struct:
type Config struct {
HTTPAddress string
HTTPPort int
}
func NewConfig() *Config {
return &Config{
HTTPAddress: "127.0.0.1", HTTPPort:
4000,
}
}This allows dynamic binding and future support for env-based configuration.

š§Ŗ Behavioral Testing withĀ FX
We created a test harness that spins up the FX app and hits real endpoints:
func StartHTTPServer() *fx.App {
a := app.NewApp() go a.Run() time.Sleep(300 * time.Millisecond) // give server time to start
return a
}
func WithTestServer(t *testing.T, testFunc func()) {
a := StartHTTPServer() defer a.Stop(context.Background()) testFunc()
}
func TestHealthEndpoint(t *testing.T) {
t.Parallel() WithTestServer(t, func() {
cfg, err := config.NewConfig() if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
addr := fmt.Sprintf("http://%s:%d", cfg.HTTPAddress, cfg.HTTPPort) resp, err :=
http.Get(addr + "/health") if err != nil {
t.Fatalf("Failed to send GET request: %v", err)
}
defer resp.Body.Close() if resp.StatusCode != http.StatusOK {
t.Errorf("Expected status 200 OK, got %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body) if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
// Parse JSON
var data map[string]string err = json.Unmarshal(body, &data) if err != nil {
t.Fatalf("Expected JSON response, got error: %v", err)
}
// Validate the message message, ok := data["message"] if !ok {
t.Fatalf("Missing 'message' field in response: %v", data)
}
if message != "OK" {
t.Errorf("Expected message 'hello world', got '%s'", message)
}
})
}š§ LessonsĀ Learned
- Uber FX simplifies lifecycle management and dependency injection.
- Modular design improves testability and clarity.
- Behavioral tests validate real-world behavior, not just isolated logic.
- Configuration makes your app flexible and production-ready.
Github commit
Conclusion
Refactoring a Gin-based Go web service with Uber FX transforms it from a monolithic structure into a modular, maintainable, and production-ready application. By leveraging FXās dependency injection and lifecycle management capabilities, we addressed key limitations of the original setupāāāhardcoded logic, poor testability, and lack of clear separation of concernsāāāwhile enhancing clarity and scalability.
The journey began with replacing a monolithic main.go with FX composition, where fx.Provide and fx.Invoke systematically declare dependencies and wire application behavior. This modular approach ensures FX resolves dependencies automatically, eliminating manual wiring and reducing errors. Lifecycle hooks, implemented through fx.Lifecycle, further improved robustness by enabling graceful server startup and shutdown, critical for reliability in production environments.
Dynamic route registration and config-driven port/address settings underscored FXās flexibility: routes are now cleanly separated from server logic, and configurations (e.g., HTTP address/port) are centralized, simplifying future adjustments like environment-specific tuning. Equally impactful was the shift to behavioral testing, where the FX app itself is spun up to validate real endpoint interactions, ensuring that the system behaves as expected in practiceāāānot just in isolated unit tests.
The lessons learned reinforce Uber FX as a powerful tool for modern Go development. It streamlines lifecycle control, makes dependency management explicit, and fosters modular designāāāall of which are foundational for building maintainable, testable, and idiomatic applications. By adopting FX, the project gains a robust architecture that supports growth, whether adding new services, integrating with external tools (like SonarQube), or adapting to evolving requirements.
This refactoring marks a significant step toward aligning the service with best practices in Go and AI-driven innovation, ensuring it remains adaptable and scalable as it evolves to meet real-world societal needs. For further enhancements, the next steps could include integrating advanced configuration sources (e.g., environment variables, external config files) or expanding lifecycle hooks to manage additional services, solidifying the applicationās foundation for future success.
A message from ourĀ Founder
Hey, Sunil here. I wanted to take a moment to thank you for reading until the end and for being a part of this community.
Did you know that our team run these publications as a volunteer effort to over 3.5m monthly readers? We donāt receive any funding, we do this to support the community. ā¤ļø
If you want to show some love, please take a moment to follow me on LinkedIn, TikTok, Instagram. You can also subscribe to our weekly newsletter.
And before you go, donāt forget to clap and follow the writerļø!
