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 → ImplementationHigh-level module → Interface ← ImplementationA. High-level depends on low-levelBadCoffeeMachine → NespressoCapsule → ElectricHeater
B. Dependency inversion designCoffeeMachine → CoffeeBeans ← NespressoCapsule → WaterHeater ← StarbucksBeans ← LocalRoastBeans ← ElectricHeater ← GasHeater ← InductionHeaterWhat 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 implementationtype 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), }}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 implementationtype TodoController struct { service TodoService}
func NewTodoController(s TodoService) *TodoController { return &TodoController{service: s}}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) // ...}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 Servicetype 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)}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.