Dependency Injection in Golang
Sebastian Pawlaczyk
Dependency Injection in Golang
How many times have you been asked about design patterns during software developer interviews? At the start of my career, I viewed them as buzzwords - concepts that seemed important but whose practical applications were unclear to me. Initially, I aimed to learn each pattern superficially, without truly understanding their real-world utility. Over time, however, I came to recognize that I had been employing these patterns in my work, often without even realizing it.
Today, I want to focus on one of the most important design patterns: Dependency Injection (DI). In this article, I will guide you through the basic concepts of Dependency Injection, illustrate its importance, and provide detailed examples to demonstrate its practical application.
Introduction
Dependency Injection (DI) is a design pattern used to achieve Inversion of Control (IoC) between classes and their dependencies. It promotes better separation of concerns by decoupling the creation of an object from its behavior.
Key Concepts:
- Inversion of Control: IoC is a principle where the control of object creation and lifecycle management is transferred from the class itself to an external entity. DI is a specific method for achieving IoC, facilitating better separation of concerns.
- Plug-In Architecture: DI supports a plug-in architecture by allowing different modules or components to be inserted or swapped in at runtime. Instead of hardcoding dependencies, you define interfaces and inject the appropriate implementations. This approach enables you to configure and extend the system with various modules without modifying the core components.
Benefits:
- Improved Modularity: By injecting dependencies, components are less dependent on each other, making the system more modular.
- Easier Testing: Dependencies can be replaced with mocks or stubs during testing, allowing for more isolated and effective unit tests.
- Reduced Coupling: Classes are less tightly coupled to their dependencies, which improves maintainability and flexibility.
Visualization
Consider an alert service that needs to notify subscribers. Depending on the environment, notifications might be sent via email or SMS. With DI, we can design the alert service to use a notifier. The implementation of the notifier can be swapped based on the environment without modifying the alert service itself. This approach ensures that changes to the notification method do not require changes to the core alert service logic.
Golang Implementation
Now, I want to present the difference between an implementation without and with dependency injection using Golang. I will use a notification example for the demonstration.
1. Without Dependency Injection:
- All in One: In this example, the AlertService is responsible for processing, validating the alert, and creating all the necessary components for sending notifications to a specific channel. This approach leads to a highly coupled system where any change in the notification logic requires modifications to the AlertService.
package main
import "fmt"
type AlertService struct {
Environment string
}
func (svc *AlertService) ProcessAlert() {
// preparing alert...
// notification
if svc.Environment == "dev" {
// 1. Prepare message for email notification
// 2. Create client for email
// 3. Invoke sending
fmt.Println("notify user by email")
} else {
// 1. Prepare message for sms notification
// 2. Create client for sms
// 3. Invoke sending
fmt.Println("notify user by sms")
}
}
func main() {
alertServiceForDev := &AlertService{
Environment: "dev",
}
alertServiceForDev.ProcessAlert()
alertServiceForProd := &AlertService{
Environment: "prod",
}
alertServiceForProd.ProcessAlert()
}
- Partial Separation: Here, even though we create separate instances for email and SMS notifiers, there are still strong, tightly coupled dependencies within the AlertService. The service knows about both EmailNotifier and SmsNotifier implementations, which leads to a system that is not fully modular or flexible.
package main
import "fmt"
type EmailNotifier struct {
// email client etc.
}
func (n *EmailNotifier) Send() {
// 1. Prepare message for email notification
// 2. Invoke sending
fmt.Println("notify user by email")
}
type SmsNotifier struct {
// sms client etc.
}
func (n *SmsNotifier) Send() {
// 1. Prepare message for sms notification
// 2. Invoke sending
fmt.Println("notify user by sms")
}
type AlertService struct {
Environment string
}
func (svc *AlertService) ProcessAlert() {
// preparing alert...
// notification
if svc.Environment == "dev" {
email := &EmailNotifier{}
email.Send()
} else {
sms := &SmsNotifier{}
sms.Send()
}
}
func main() {
alertServiceForDev := &AlertService{
Environment: "dev",
}
alertServiceForDev.ProcessAlert()
alertServiceForProd := &AlertService{
Environment: "prod",
}
alertServiceForProd.ProcessAlert()
}
2. With Dependency Injection
In this version, I introduce a Notifier interface. The AlertService is now decoupled from the specific implementations of notifiers; it only depends on the Notifier interface. Each new notification struct needs to implement the Send() function and can easily be plugged into the system without modifying the AlertService.
package main
import "fmt"
type Notifier interface {
Send()
}
type emailNotifier struct {
// email client etc.
}
func NewEmailNotifier() Notifier {
return &emailNotifier{}
}
func (n *emailNotifier) Send() {
fmt.Println("notify user by email")
}
type smsNotifier struct {
// sms client etc.
}
func NewSmsNotifier() Notifier {
return &smsNotifier{}
}
func (n *smsNotifier) Send() {
fmt.Println("notify user by sms")
}
type AlertService struct {
notifier Notifier
}
func NewAlertService(notifier Notifier) *AlertService {
return &AlertService{notifier: notifier}
}
func (svc *AlertService) ProcessAlert() {
// preparing alert...
// notification
svc.notifier.Send()
}
func main() {
env := "prod"
var notifier Notifier
if env == "dev" {
notifier = NewEmailNotifier()
} else {
notifier = NewSmsNotifier()
}
alertService := NewAlertService(notifier)
alertService.ProcessAlert()
}
Golang Frameworks for Dependency Injection
By default, the Go language does not provide built-in functionality to automate dependency injection. Imagine having a large number of dependencies that you need to manually initialize, extend constructors for, and pass around. This can become annoying and prone to errors. To simplify this process, several external frameworks have been developed. Here are two popular ones:
1. Google - Wire
Wire offers a minimalist and explicit setup, making it ideal for small, uncomplicated applications.
Example:
- Install wire from: wire-repo
- Create a wire.go file with all the dependencies needed for a specific struct:
//go:build wireinject
// +build wireinject
package main
import "github.com/google/wire"
func InitializeAlertService() AlertService {
wire.Build(NewAlertService, NewEmailNotifier)
return nil
}
- Generate the code using the wire command:
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package main
// Injectors from wire.go:
func InitializeAlertService() AlertService {
notifier := NewEmailNotifier()
mainAlertService := NewAlertService(notifier)
return mainAlertService
}
- You’re now ready to run the generated function!
2. Uber - Fx
Fx is designed for large-scale applications, offering a range of additional features like middleware and lifecycle management. Here’s a brief demonstration of its capabilities.
Example:
- Get fx from: fx-repo
- Use fx.Provide to specify constructors.
- Use fx.Invoke to define what to do after dependencies are resolved.
- Use app.Run() to start the application.
package main
func main() {
app := fx.New(
fx.Provide(
NewEmailNotifier,
NewAlertService,
),
fx.Invoke(
func(svc AlertService) {
svc.ProcessAlert()
},
),
)
app.Run()
}
Conclusions
Dependency Injection not only enhances modularity but also encourages the creation of well-organized code. By pushing developers to define clear interfaces for specific services, it naturally leads to applications that are easier to test and extend. I highly recommend starting with the frameworks described, as they will save you time and simplify your development process.