Before I joined my current team, the word "dependency injection" felt like an academic buzzword. We built some of our services in Go, and frankly, I didn't see the point of introducing a framework for something that could be done manually.
That mindset changed the moment we had to write robust integration tests for a new a complex API. Our challenge was to ensure a critical user flowâinvolving user authentication, data processing, and persistenceâworked flawlessly. We needed to test the service without hitting a real, shared database instance, which would lead to flaky, non-deterministic tests.
This is where dependency injection (DI), and specifically the fx framework, transformed our approach.
The Problem: Tightly Coupled Components
Here's a simplified example to illustrate the problem. Our service, in its initial form, was a mess of hard-coded dependencies. An OrderProcessor would directly instantiate a DatabaseClient, which made testing difficult. We couldn't swap out the real database for a mock since the OrderProcessor was tightly coupled to the concrete implementation.
// Tightly coupled code
type OrderProcessor struct {
dbClient *database.Client // A concrete implementation
}
func NewOrderProcessor() *OrderProcessor {
// The dependency is created inside the service itself
client := database.NewClient(options)
return &OrderProcessor{dbClient: client}
}
This is a classic violation of the Dependency Inversion Principle, where high-level modules (our OrderProcessor) shouldn't depend on low-level modules (the DatabaseClient implementation). Instead, both should depend on abstractions (interfaces).
The Solution: Inverting Control with fx
Fx allowed us to flip this on its head. We refactored our code to depend on interfaces.
First, we defined the contracts:
// An abstraction for the database client
type DatabaseClient interface {
Get(key string) (string, error)
Put(key, value string) error
}
// An abstraction for our service
type OrderProcessor interface {
ProcessOrder(orderID string) error
}
Next, we made our OrderProcessor a provider function that simply declared what it needs to work:
// Our service now depends on the DatabaseClient interface, not a concrete type.
func NewOrderProcessor(db DatabaseClient, logger Logger) OrderProcessor {
return &orderProcessor{
db: db,
logger: logger,
}
}
The magic of fx is that it handled the "wiring." In our main application, we provided the real implementations.
// In main.go for production
app := fx.New(
fx.Provide(NewDatabaseClient), // Provides the real database client
fx.Provide(NewLogger), // Provides the real logger
fx.Provide(NewOrderProcessor),
fx.Invoke(StartService),
)
As for our integration tests, we did something completely different. We provided mock implementations instead.
The Payoff: Integration Tests with Confidence
This is where DI truly shines. Our test suite could now create an fx application that uses fakes and mocks.
// In order_processor_test.go
func TestOrderProcessor(t *testing.T) {
// Create a mock database client for the test.
mockDB := &MockDatabaseClient{}
mockLogger := &MockLogger{}
// Manually create the service with the mock dependencies.
service := NewOrderProcessor(mockDB, mockLogger)
// Now we can test the service's logic without any external dependencies.
orderID := "order-123"
// This test runs in milliseconds because it doesn't need a real database connection.
err := service.ProcessOrder(orderID)
assert.NoError(t, err)
// We can even assert on what methods were called on our mock.
assert.Equal(t, 1, mockDB.Calls("Get"))
}
This approach gave us several powerful benefits:
-
Speed: Our tests went from being slow and network-dependent to running in a fraction of a second.
-
Reliability: Tests no longer failed due to network latency, connection issues, or shared state. They were completely deterministic.
-
Isolation: We could test our OrderProcessor logic in complete isolation from the external world, ensuring it did exactly what we expected with the dependencies we provided.
-
Code Clarity: The dependency signatures made it immediately obvious what each component needed to function.
Ultimately, integrating fx wasn't about adding complexity; it was about embracing a pattern that simplified our application's design and made it infinitely more testable and maintainable. For any developer building a service with external dependencies, DI is not just a good practiceâit's a game-changer.