Book Image

Hands-On Dependency Injection in Go

By : Corey Scott
Book Image

Hands-On Dependency Injection in Go

By: Corey Scott

Overview of this book

Hands-On Dependency Injection in Go takes you on a journey, teaching you about refactoring existing code to adopt dependency injection (DI) using various methods available in Go. Of the six methods introduced in this book, some are conventional, such as constructor or method injection, and some unconventional, such as just-in-time or config injection. Each method is explained in detail, focusing on their strengths and weaknesses, and is followed with a step-by-step example of how to apply it. With plenty of examples, you will learn how to leverage DI to transform code into something simple and flexible. You will also discover how to generate and leverage the dependency graph to spot and eliminate issues. Throughout the book, you will learn to leverage DI in combination with test stubs and mocks to test otherwise tricky or impossible scenarios. Hands-On Dependency Injection in Go takes a pragmatic approach and focuses heavily on the code, user experience, and how to achieve long-term benefits through incremental changes. By the end of this book, you will have produced clean code that’s easy to test.
Table of Contents (15 chapters)

Why does DI matter?

As professionals, we should never stop learning. Learning is the one true way to ensure we stay in demand and continue delivering value to our customers. Doctors, lawyers, and scientists are all highly respected professionals and all focus on continuously learning. Why should programmers be different?

In this book, we will take a journey that will start with some code that gets the job done and, by selectively applying various DI methods available in Go, together, we will transform it into something a hell of a lot easier to maintain, test, and extend.

Not everything in this book is traditional or perhaps even idiomatic, but I would ask you to try it before you deny it. If you like it, fantastic. If not, at least you learned what you don't want to do.

So, how do I define DI?

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.