Dependency Injection - Make Swappable & Testable module!

Introduction

Typically, projects of certain scale manage through architectural layering, and recently I studied how to better replace modules via “dependency injection” for cleaner testing. The concept of architectural layering can refer to a previous article: Building Express.js project by Utilizing MVC Pattern

Problem: High-level Modules Depend on Low-level Modules

Imagine if a laptop (high-level module) had a power cable (low-level module) soldered directly onto it. While simple and convenient, it leads to…

  • Want to change to a different charge port → Have to open the entire laptop for modification
  • Different voltage → Have to open the entire laptop for modification

Similarly, for a program’s image upload feature…

  • Want to change to a different storage provider → Need to edit the entire image upload feature
  • Want to expand to a different storage provider → Need to edit the entire image upload feature

High-level modules should not be tied to low-level implementations, just as when learning to drive, one does not focus on driving a specific brand of vehicle but instead these low-level modules “implement” a specific interface, like “driving skills (steering wheel, brakes, gear shift, etc.)”.

Any changes made to the program represent a greater understanding burden and interdependencies. To achieve a design that is “closed for modification but open for extension,” appropriately creating abstract interfaces is a means to enhance the program’s extensibility.

Dependency Inversion Principle (DIP)

The solution to the problem mentioned in the previous example is essentially to make modules depend on “interfaces” rather than concrete “implementations,” which is the “Dependency Inversion Principle”:

High-level module → Implementation
High-level module → Interface ← Implementation
A. High-level depends on low-level
BadCoffeeMachine → NespressoCapsule
→ ElectricHeater
B. Dependency inversion design
CoffeeMachine → CoffeeBeans ← NespressoCapsule
→ WaterHeater ← StarbucksBeans
← LocalRoastBeans
← ElectricHeater
← GasHeater
← InductionHeater

What is Dependency Injection

As mentioned earlier, dependency inversion achieves greater flexibility, and dependency injection is one of the means to accomplish this.

Dependency injection is an implementation of Inversion of Control (IoC) that moves “dependency creation” to external control.

The “internal” can be classes, functions, structs, etc., and injection logic entails not creating dependency objects within the module itself but passing them in from the outside.

  • Without DI:
    • The module decides which dependency to use
  • With DI:
    • The module declares what it needs, and the external environment decides what to provide

Real Case: Building a More Extendable Architecture with DI

The following uses Go for CRUD operations on Todo items as an example, demonstrating how to achieve a more flexible program architecture through DI.

1. High-level Modules Depend on Low-level Modules

Originally, the Service was directly created within the Controller, leading to tight coupling. This made it difficult to mock the Service or change the DB when writing tests without touching the Controller:

// Controller directly depends on concrete TodoService implementation
type TodoController struct {
service *TodoService
}
func NewTodoService(db *mongo.Database) TodoService {
return &todoService{
collection: db.Collection("todos"),
}
}
func NewTodoController(db *mongo.Database) *TodoController {
// Controller creates dependencies internally, hard to replace
return &TodoController{
service: NewTodoService(db),
}
}

❌ No DI

depends on

depends on

TodoController

TodoService

MongoDB

2. Implementing Dependency Inversion Through Interfaces

By defining interfaces, high-level modules (Controller) depend on abstractions, making the Controller indifferent to how the Service is implemented and allowing for more flexible replacement with mock services based on abstractions:

// Define an interface (abstraction)
type TodoService interface {
Create(todo *model.Todo) error
GetAll() ([]model.Todo, error)
GetByID(id string) (*model.Todo, error)
Update(id string, todo *model.Todo) error
Delete(id string) error
}
// Controller depends on the interface rather than concrete implementation
type TodoController struct {
service TodoService
}
func NewTodoController(s TodoService) *TodoController {
return &TodoController{service: s}
}

✅ With DI - Depend on Interface

depends on

implements

TodoController

TodoService
interface

TodoService
implementation

3. Constructor Injection DI

  • Constructor: A function executed when creating an object
  • Constructor Injection = Passing the required dependencies into the constructor

Here, it is simply a function that initializes assembling the dependencies to create the desired Controller, which is one way to achieve DI.

func main() {
// 1. Set up DB
client, _ := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
db := client.Database("todo_db")
// 2. Assemble dependencies
todoService := service.NewTodoService(db)
todoController := controller.NewTodoController(todoService)
// 3. Set routing
r := gin.Default()
r.POST("/todos", todoController.Create)
r.GET("/todos", todoController.GetAll)
// ...
}

main.go

inject

inject

register

TodoController

MongoDB

TodoService
implementation

Gin Router

4. Testing Advantages Brought by DI

While DI makes expansion flexible and appears impressive, it also increases complexity. If initially there is only one implementation, why complicate things so much?

Indeed, this is an area open to negotiation, but a factor worth considering during development is: “testing.”

Typically, for the sake of separation of concerns and efficiency, test code exists within boundaries, necessitating corresponding test code at different levels. Through DI, injected modules can be easily replaced as long as they adhere to the interface.

import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mock implementation of Service
type MockTodoService struct {
mock.Mock
}
func (m *MockTodoService) Create(todo *model.Todo) error {
args := m.Called(todo)
return args.Error(0)
}
// ... other method implementations
func TestCreateTodo(t *testing.T) {
// GIVEN: Create Mock and set expected behavior
mockService := new(MockTodoService)
mockService.On("Create", mock.Anything).Return(nil)
// Inject MockService into Controller
controller := NewTodoController(mockService)
// WHEN: Execute the test
// ... send HTTP request
// THEN: Validate
mockService.AssertExpectations(t)
}

🧪 Testing Environment

TodoController

MockTodoService

🚀 Production Environment

TodoController

TodoService

MongoDB

Conclusion

I uploaded the go-gin-testing-todos🔗 example demonstrating testing architecture through DI.

In this article, DI is implemented through polymorphic interfaces. One aspect I am not very familiar with is that, every time entering TodoService, I will see the interface definition rather than the implementation, necessitating checking the editor for the Go implementation to see existing implementations.

DI Architecture

depends on

implements

implements

TodoController

TodoService
Interface

TodoService

MockTodoService

Traditional Tight Coupling Architecture

depends on

TodoController

TodoService

Further Reading