dingo: Putting it all together with dingo — a YAML configurable DI framework.
Overview and TerminologyDI literally means to inject your dependencies.
A dependency can be anything that effects the behavior or outcome of your logic.
Some common examples are:Other services.
Making your code more modular, less duplicate code and more testable.
Configuration.
Such as a database passwords, API URL endpoints, etc.
System or environment state.
Such as the clock or file system.
This comes in extremely important when writing tests that depend on time or random data.
Stubs of external APIs.
So that API requests can be mocked within the system during tests to keep things stable and quick.
Some terminology:A service is an instance of a class.
It’s called a service because its often referred to by name rather than type.
For example Emailer is the name of a service, but it is an instance of a SendEmail.
We can change the underlying implementation of a service.
As long as it has the same interface we need not rename the service.
A container is a collection of services.
Services are lazy-loaded and only initialized when they are requested from the container.
A singleton is an instance that is initialised once, but can be reused many times.
Simple ExampleLet’s consider a very simple example.
We have a service that sends an email:type SendEmail struct { From string}func (sender *SendEmail) Send(to, subject, body string) error { // It sends an email here, and perhaps returns an error.
}We also have a service that welcomes new customers:type CustomerWelcome struct{}func (welcomer *CustomerWelcome) Welcome(name, email string) error { body := fmt.
Sprintf("Hi, %s!", name) subject := "Welcome" emailer := &SendEmail{ From: "hi@welcome.
com", } return emailer.
Send(email, subject, body)}We could use it like:welcomer := &CustomerWelcome{}err := welcomer.
Welcome("Bob", "bob@smith.
com")// check error.
It looks good.
However, already we have run into one major problem.
This is difficult to unit test.
We don’t want to actually send out emails, but we still want to verify that the correct customer receives the correctly formatted email message.
This is where DI comes in.
If we provide (inject) in the dependency (the SendEmail, in this case) we can provide a fake one during test.
Let’s refactor our CustomerWelcome:// EmailSender provides an interface so we can swap out the// implementation of SendEmail under tests.
type EmailSender interface { Send(to, subject, body string) error}type CustomerWelcome struct{ Emailer EmailSender}func (welcomer *CustomerWelcome) Welcome(name, email string) error { body := fmt.
Sprintf("Hi, %s!", name) subject := "Welcome" return welcomer.
Emailer.
Send(email, subject, body)}The usage becomes more complicated because we are now injecting the EmailSender:emailer := &SendEmail{ From: "hi@welcome.
com",}welcomer := &CustomerWelcome{ Emailer: emailer,}err := welcomer.
Welcome("Bob", "bob@smith.
com")// check error.
However, now this is unit testable:import ( "github.
com/stretchr/testify/assert" "github.
com/stretchr/testify/mock")type FakeEmailSender struct { mock.
Mock}func (mock *FakeEmailSender) Send(to, subject, body string) error { args := mock.
Called(to, subject, body) return args.
Error(0)}func TestCustomerWelcome_Welcome(t *testing.
T) { emailer := &FakeEmailSender{} emailer.
On("Send", "bob@smith.
com", "Welcome", "Hi, Bob!").
Return(nil) welcomer := &CustomerWelcome{ Emailer: emailer, } err := welcomer.
Welcome("Bob", "bob@smith.
com") assert.
NoError(t, err) emailer.
AssertExpectations(t)}With Great Complexity Comes Great ResponsiblyFundamentally this principle of extracting dependencies works great.
However, everything in technology is a trade off.
If we continue to follow this path we will see:Duplicate code.
Imagine needing to use CustomerWelcome in more than one place (several, or even dozens).
Now we have duplicate code that initialises the SendEmail.
Especially repeating the From.
Argh!Complexity and misunderstanding.
To use a service we now have to know how to setup all of its dependencies.
Each of its dependencies may, in turn, have their own.
Also, there may be several ways to satisfy dependencies that compile correctly but provide the wrong runtime logic.
For example, if we has a EmailCustomer and EmailSupplier that both implemented EmailSender .
We might provide the wrong service and a customer receives a message that should have been sent to a supplier.
Oops!Maintainability rapidly decreases.
If a service initialization needs to change, or it needs new dependencies you now have to refactor all cases where it is used.
Or worse, you miss something, such as changing the From address in the SendEmail .
And now some emails are being sent with the wrong reply address.
Oh no!Don’t worry, there are solutions for these as well.
Read on.
Building the Services With FunctionsOne fairly obvious solution is to use “create” functions that build the services for us.
That is, one function is responsible for building one service:func CreateSendEmail() *SendEmail { return &SendEmail{ From: "hi@welcome.
com", }}func CreateCustomerWelcome() *CustomerWelcome { return &CustomerWelcome{ Emailer: CreateSendEmail(), }}Now we can use it more simply and safely:welcomer := CreateCustomerWelcome()err := welcomer.
Welcome("Bob", "bob@smith.
com")// check error.
The unit tests can also be updated:func TestCustomerWelcome_Welcome(t *testing.
T) { emailer := &FakeEmailSender{} emailer.
On("Send", "bob@smith.
com", "Welcome", "Hi, Bob!").
Return(nil) welcomer := CreateCustomerWelcome() welcomer.
Emailer = emailer err := welcomer.
Welcome("Bob", "bob@smith.
com") assert.
NoError(t, err) emailer.
AssertExpectations(t)}A few things to note:I’ve used a Created prefix, rather than New as New is commonly associated with constructors in Go.
The unit test does not need to use CreateCustomerWelcome().
In fact you can leave it how it was.
One advantage of replacing it with the create function is that if the definition of that service changes your unit tests will be less brittle.
However, this is also a disadvantage because you might miss some key refactoring needed for the tests that are now failing.
Again, trade offs.
SingletonsA singleton is an instance that is initialised once, but can be reused many times.
Up until now we have been creating a new instance every time we call a service.
Especially in larger, more complex codebases this is quite wasteful.
Not so much in terms of memory usage/garbage collection, but more in the way of dealing with services that are expensive to load.
For example, if we had a CustomerManager that loaded all customers into memory from a file.
If we knew didn’t change, we would surely only want to do this once rather than every time we would want to lookup a customer.
Getting back to the original example, CustomerWelcome is stateless.
That means that we can safely reuse it without needing to create a new one each time.
SendEmail does actually have state, the From.
However, we also consider this to be stateless because its a value that does not change throughout a single run of the application.
We can refactor our functions into a container to make our sevices singletons:type Container struct { CustomerWelcome *CustomerWelcome SendEmail EmailSender)func (container *Container) GetSendEmail() EmailSender { if container.
SendEmail == nil { container.
SendEmail = &SendEmail{ From: "hi@welcome.
com", } } return container.
SendEmail}func (container *Container) GetCustomerWelcome() *CustomerWelcome { if container.
CustomerWelcome == nil { container.
CustomerWelcome = &CustomerWelcome{ Emailer: container.
GetSendEmail(), } } return container.
CustomerWelcome}The functions are now attached to a struct called Container.
This is because if we allowed the services to remain global it would affect the unit tests.
Making a change to service would persist through tests and lead to some strange and hard to debug issues.
Each unit test should create a new Container:func TestCustomerWelcome_Welcome(t *testing.
T) { emailer := &FakeEmailSender{} emailer.
On("Send", "bob@smith.
com", "Welcome", "Hi, Bob!").
Return(nil) container := &Container{} container.
SendEmail = emailer welcomer := container.
GetCustomerWelcome() err := welcomer.
Welcome("Bob", "bob@smith.
com") assert.
NoError(t, err) emailer.
AssertExpectations(t)}Also, I have renamed the functions with a Get prefix because they may return a new service, or the already initialized service (singleton).
Introducing: ???? dingoWe’ve covered most of the basics of DI so far.
However, one issue that still remains is that there is still a lot of code we need to create to configure the services.
Since most services are configured the same way with slight variations we can use an intermediate language, YAML to describe what the services are rather than how they should be initialised.
Introducing dingo, the first type-safe DI framework for Go.
It reads YAML and generates the necessary Go code to build your container.
Using YAML is easier and safer than trying to do it by hand.
Let’s consider the original example.
What would the configuration look like?.Well we create a file in the root of the project called dingo.
yml:services: SendEmail: type: *SendEmail interface: EmailSender properties: From: '"hi@welcome.
com"' CustomerWelcome: type: *CustomerWelcome properties: Emailer: '@{SendEmail}'We need only to run the command:dingoThis will generate a new file called dingo.
go with the DefaultContainer ready to use:welcomer := DefaultContainer.
GetCustomerWelcome()err := welcomer.
Welcome("Bob", "bob@smith.
com")// check error.
dingo has many more advanced features that I hope to explain in more detail in future articles.
Until then, I hope that it saves you time and would love to hear your feedback.
.