Dependency injection is used to provide the necessary dependencies to the objects from external sources rather than creating the dependencies inside the objects.
For example, instead of a service creating its own dependencies, you inject those dependencies from outside, allowing you to easily swap them for different implementations or mock objects during testing.
For this blogpost we are going to use a dependency injection tool called samber/do. Why we use this package?
- it is simple
- it is lazily loaded.
IMPLEMENTATION
We are going to explore the package by creating a simple service. Now image that we have a service which processes the orders for the website. This service depends on 2 other services called PaymentProcessor
and NotificationService
.
Let’s follow the best practices and define the interfaces for our dependencies.
1
2
3
4
5
6
7
8
9
| package main
type PaymentProcessor interface {
ProcessPayment(amount float64) error
}
type NotificationService interface {
SendNotification(orderID string) error
}
|
Now let’s have some basic implementations for our services.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| package main
import "github.com/rs/zerolog"
type EmailNotification struct {
logger zerolog.Logger
}
func (n *EmailNotification) SendNotification(orderID string) error {
n.logger.Printf("Sending email notification for order %s", orderID)
return nil
}
func NewEmailNotification(logger zerolog.Logger) *EmailNotification {
return &EmailNotification{logger: logger}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| package main
import "github.com/rs/zerolog"
type StripeProcessor struct {
logger zerolog.Logger
}
func (p *StripeProcessor) ProcessPayment(amount float64) error {
p.logger.Printf("Processing payment of $%.2f through Stripe", amount)
return nil
}
func NewStripeProcessor(logger zerolog.Logger) *StripeProcessor {
return &StripeProcessor{logger: logger}
}
|
As we can see
- the
StripeProcessor
implements the PaymentProcessor
inferface. - the
EmailNotification
implements the NotificationService
interface.
Now let’s create the order service with best practises
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| package main
type OrderService struct {
paymentProcessor PaymentProcessor
notificationService NotificationService
}
func NewOrderService(paymentProcessor PaymentProcessor, notificationService NotificationService) *OrderService {
return &OrderService{
paymentProcessor: paymentProcessor,
notificationService: notificationService,
}
}
func (s *OrderService) ProcessOrder(orderID string, amount float64) error {
err := s.paymentProcessor.ProcessPayment(amount)
if err != nil {
return err
}
return s.notificationService.SendNotification(orderID)
}
|
We have the simple implementation ready. Let’s do the dependency injection without and with samber/do
package.
Straight Forward Injection
It is pretty straightforward, you just need to initiaze the necessary dependency by yourself to provide the services by you.
Now for the small cases, such as this seems fine, but as we can see it lacks some kind of generalization, structure.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| package main
import (
"github.com/rs/zerolog"
)
func main() {
logger := zerolog.New(os.Stdout).Level(zerolog.DebugLevel)
stripeProcessor := NewStripeProcessor(logger)
emailNotif := NewEmailNotification(logger)
orderService := NewOrderService(stripeProcessor, emailNotif)
// Use the service
err := orderService.ProcessOrder("12345", 100.00)
if err != nil {
panic(err)
}
}
|
Structured Injection
It seems a little bit complicated but I am going to explain bit by bit.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| package main
import (
"os"
"github.com/rs/zerolog"
"github.com/samber/do"
)
func main() {
injector := do.New()
do.Provide(injector, func(i *do.Injector) (zerolog.Logger, error) {
return zerolog.New(os.Stdout).Level(zerolog.DebugLevel), nil
})
// Bind implementations to the interfaces
do.Provide(injector, func(i *do.Injector) (PaymentProcessor, error) {
logger := do.MustInvoke[zerolog.Logger](i)
return NewStripeProcessor(logger), nil
})
// Bind implementations to the interfaces
do.Provide(injector, func(i *do.Injector) (NotificationService, error) {
logger := do.MustInvoke[zerolog.Logger](i)
return NewEmailNotification(logger), nil
})
do.Provide(injector, func(i *do.Injector) (*OrderService, error) {
paymentProcessor := do.MustInvoke[PaymentProcessor](i)
notificationService := do.MustInvoke[NotificationService](i)
return NewOrderService(paymentProcessor, notificationService), nil
})
// Resolve the OrderService and use it
orderService := do.MustInvoke[*OrderService](injector)
err := orderService.ProcessOrder("12345", 100.00)
if err != nil {
panic(err)
}
}
|
With this package, to use something, first you need to provide it. There are multiple way to provide something,
- Provide: It is provided anonymously, and it is loaded lazily. You can see the example, we provided the type we want to inject.
- ProvideNamed: This is same as the Provide but we define a tag to identify the object we want to provide. It is mostly useful for the case where you need to provide multiple different instances of the same type (such as multiple different loggers). To define them, a tag is needed. For example
1
2
3
4
5
6
7
8
9
10
11
12
13
| injector := do.New()
do.ProvideNamed(injector, "debugLogger", func(i *do.Injector) (zerolog.Logger, error) {
return zerolog.New(os.Stdout).Level(zerolog.DebugLevel), nil
})
do.ProvideNamed(injector, "errorLogger", func(i *do.Injector) (zerolog.Logger, error) {
return zerolog.New(os.Stdout).Level(zerolog.ErrorLevel), nil
})
// To get them use do.MustInvokeNamed
debugLogger := do.MustInvokeNamed[zerolog.Logger](injector, "debugLogger")
errorLogger := do.MustInvokeNamed[zerolog.Logger](injector, "errorLogger")
|
These 2 function are loaded lazily which means that if we provided something but we did not call invoke on them, these instances are not created. It is one of the most benefical features of samber/do
package.
With this package, to access something we provided, we need to invoke them. You can check the examples from code.
With this approach you can even have a helper function, which returns the injector and you can access each of the objects inside.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| package main
import (
"os"
"github.com/rs/zerolog"
"github.com/samber/do"
)
func Injector() *do.Injector {
injector := do.New()
do.Provide(injector, func(i *do.Injector) (zerolog.Logger, error) {
return zerolog.New(os.Stdout).Level(zerolog.DebugLevel), nil
})
// Bind implementations to the interfaces
do.Provide(injector, func(i *do.Injector) (PaymentProcessor, error) {
logger := do.MustInvoke[zerolog.Logger](i)
return NewStripeProcessor(logger), nil
})
// Bind implementations to the interfaces
do.Provide(injector, func(i *do.Injector) (NotificationService, error) {
logger := do.MustInvoke[zerolog.Logger](i)
return NewEmailNotification(logger), nil
})
do.Provide(injector, func(i *do.Injector) (*OrderService, error) {
paymentProcessor := do.MustInvoke[PaymentProcessor](i)
notificationService := do.MustInvoke[NotificationService](i)
return NewOrderService(paymentProcessor, notificationService), nil
})
return injector
}
|
Now our main function just becomes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| package main
import (
"github.com/samber/do"
)
func main() {
injector := Injector()
// Resolve the OrderService and use it
orderService := do.MustInvoke[*OrderService](injector)
err := orderService.ProcessOrder("12345", 100.00)
if err != nil {
panic(err)
}
}
|
As we can see we cleaned up most of the stuff from main function which is the ideal case.
Summary
with the samber/do package
- all the dependency bindings are centralized.
- lazy initialization is used.
- automatically handle the resolution of dependencies in correct order.
- scalability is easier. As your project grows, adding new dependencies are easy.