DI is coding in such a way that those resources (that is, functions or structs) that we depend on are abstractions. Because these dependencies are abstract, changes to them do not necessitate changes to our code. The fancy word for this is decoupling.
The use of the word abstraction here may be a little misleading. I do not mean an abstract class like you find in Java; Go does not have that. Go does, however, have interfaces and function literals (also known as closures).
Consider the following example of an interface and the SavePerson() function that uses it:
// Saver persists the supplied bytes
type Saver interface {
Save(data []byte) error
}
// SavePerson will validate and persist the supplied person
func SavePerson(person *Person, saver Saver) error {
// validate the inputs
err := person.validate()
if err != nil {
return err
}
// encode person to bytes
bytes, err := person.encode()
if err != nil {
return err
}
// save the person and return the result
return saver.Save(bytes)
}
// Person data object
type Person struct {
Name string
Phone string
}
// validate the person object
func (p *Person) validate() error {
if p.Name == "" {
return errors.New("name missing")
}
if p.Phone == "" {
return errors.New("phone missing")
}
return nil
}
// convert the person into bytes
func (p *Person) encode() ([]byte, error) {
return json.Marshal(p)
}
In the preceding example, what does Saver do? It saves some bytes somewhere. How does it do this? We don't know and, while working on the SavePerson function, we don't care.
Let's look at another example that uses a function literal:
// LoadPerson will load the requested person by ID.
// Errors include: invalid ID, missing person and failure to load
// or decode.
func LoadPerson(ID int, decodePerson func(data []byte) *Person) (*Person, error) {
// validate the input
if ID <= 0 {
return nil, fmt.Errorf("invalid ID '%d' supplied", ID)
}
// load from storage
bytes, err := loadPerson(ID)
if err != nil {
return nil, err
}
// decode bytes and return
return decodePerson(bytes), nil
}
What does decodePerson do? It converts the bytes into a person. How? We don't need to know to right now.
This is the first advantage of DI that I would highlight to you:
DI reduces the knowledge required when working on a piece of code, by expressing dependencies in an abstract or generic manner
Now, let's say that the preceding code came from a system that stored data in a Network File Share (NFS). How would we write unit tests for that? Having access to an NFS at all times would be a pain. Any such tests would also fail more often than they should due to entirely unrelated issues, such as network connectivity.
On the other hand, by relying on an abstraction, we could swap out the code that saves to the NFS with fake code. This way, we are only testing our code in isolation from the NFS, as shown in the following code:
func TestSavePerson_happyPath(t *testing.T) {
// input
in := &Person{
Name: "Sophia",
Phone: "0123456789",
}
// mock the NFS
mockNFS := &mockSaver{}
mockNFS.On("Save", mock.Anything).Return(nil).Once()
// Call Save
resultErr := SavePerson(in, mockNFS)
// validate result
assert.NoError(t, resultErr)
assert.True(t, mockNFS.AssertExpectations(t))
}
Don't worry if the preceding code looks unfamiliar; we will examine all of the parts in depth later in this book.
Which brings us to the second advantage of DI:
DI enables us to test our code in isolation of our dependencies
Considering the earlier example, how could we test our error-handling code? We could shut down the NFS through some external script every time we run the tests, but this would likely be slow and would definitely annoy anyone else that depended on it.
On the other hand, we could quickly make a fake Saver that always failed, as shown in the following code:
func TestSavePerson_nfsAlwaysFails(t *testing.T) {
// input
in := &Person{
Name: "Sophia",
Phone: "0123456789",
}
// mock the NFS
mockNFS := &mockSaver{}
mockNFS.On("Save", mock.Anything).Return(errors.New("save failed")).Once()
// Call Save
resultErr := SavePerson(in, mockNFS)
// validate result
assert.Error(t, resultErr)
assert.True(t, mockNFS.AssertExpectations(t))
}
The above test is fast, predictable, and reliable. Everything we could want from tests!
This gives us the third advantage of DI:
DI enables us to quickly and reliably test situations that are otherwise difficult or impossible
Let's not forget about the traditional sales pitch for DI. Tomorrow, if we decided to save to a NoSQL database instead of our NFS, how would our SavePerson code have to change? Not one bit. We would only need to write a new Saver implementation, giving us the fourth advantage of DI:
DI reduces the impact of extensions or changes
At the end of the day, DI is a tool—a handy tool, but no magic bullet. It's a tool that can make code easier to understand, test, extend, and reuse—a tool that can also help reduce the likelihood of circular dependency issues that commonly plague new Go developers.