🧠 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:

go
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:

go
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 *Config if 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.

go
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.ā€
🧠 Refactoring a Go Web Service with Uber FX: From Monolith to Modular Elegance

2. Lifecycle-Aware ServerĀ Startup

We split server creation and startup into two functions:

go
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:

go
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:

go
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.

🧠 Refactoring a Go Web Service with Uber FX: From Monolith to Modular Elegance

🧪 Behavioral Testing with FX

We created a test harness that spins up the FX app and hits real endpoints:

go
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ļø!