Learning

Using Structs to Facilitate Dependency Injection with Go

Luca Guidi's profile picture Luca Guidi on

When working on DNSimple projects written in Go, I'm always fascinated by its power. But sometimes I miss the ease of use of other languages like Ruby (my primary language). With Ruby, for instance, injecting a dependency is just a matter of passing it to a constructor. With Go, it's less immediate, but still possible.

To demonstrate the technique I use, let's build a very simple HTTP client to consume an API and print the result.

First iteration

The most immediate implementation is to have all the business logic inside the command line file.

// cmd/timenow.go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"time"
)

const (
	apiEndpoint string = "http://worldclockapi.com/api/json/utc/now"
	timeFormat  string = "2006-01-02T15:04Z"
)

type timeResponse struct {
	Now string `json:"currentDateTime,omitempty"`
}

func main() {
	resp, err := http.Get(apiEndpoint)

	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		fmt.Fprintln(os.Stderr, fmt.Sprintf("cannot contact server (HTTP response %d)", resp.StatusCode))
		os.Exit(1)
	}

	tr := timeResponse{}
	err = json.NewDecoder(resp.Body).Decode(&tr)
	if err != nil {
		fmt.Fprintln(os.Stderr, fmt.Sprintf("cannot decode server response (%s)", err.Error()))
		os.Exit(1)
	}

	timeNow, err := time.Parse(timeFormat, tr.Now)
	if err != nil {
		fmt.Fprintln(os.Stderr, fmt.Sprintf("cannot parse time (%s)", err.Error()))
		os.Exit(1)
	}
	
	now := timeNow.Format(time.UnixTime)

	fmt.Println(now)
}
$ go run cmd/timenow.go
Fri Nov 7 09:41:00 UTC 2018

While this command line client works as is, I'm going to have a hard time testing and maintaining it.

It's hard to maintain, because the imperative code (command line) is mixed with business code. All the responsibilities, such as command line handling and data fetching, are mixed together.

For similar reasons it's hard to test, because we need to simulate all the scenarios by invoking the compiled command, and by avoiding hitting the external server.

Second iteration

I solved the most immediate problem: the separation between command line (CLI) and business logic. The only feature of this project has become a library that can be used by third-party developers to solve this problem. They can use it to build a CLI or get current time in their app.

// timenow.go
package timenow

import (
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	"github.com/pkg/errors"
)

const (
	apiEndpoint string = "http://worldclockapi.com/api/json/utc/now"
	timeFormat  string = "2006-01-02T15:04Z"
)

type timeResponse struct {
	Now string `json:"currentDateTime,omitempty"`
}

func Execute() (string, error) {
	resp, err := http.Get(apiEndpoint)

	if err != nil {
		err = errors.Wrap(err, "failed to contact API")
		return "", err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		err = errors.Wrap(err, fmt.Sprintf("non successful API response (%d)", resp.StatusCode))
		return "", err
	}

	tr := timeResponse{}
	err = json.NewDecoder(resp.Body).Decode(&tr)
	if err != nil {
		err = errors.Wrap(err, "cannot decode server response")
		return "", err
	}

	timeNow, err := time.Parse(timeFormat, tr.Now)
	if err != nil {
		err = errors.Wrap(err, "cannot parse time")
		return "", err
	}

	now := timeNow.Format(time.UnixDate)

	return now, nil
}

The command line is now simplified and can be easily tested by simulating the happy path and an error.

// cmd/timenow.go
package main

import (
	"fmt"
	"os"

	"github.com/jodosha/timenow"
)

func main() {
	now, err := timenow.Execute()
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	fmt.Println(now)
}

By running the command it still works:

$ go run cmd/timenow.go
Fri Nov 7 10:25:00 UTC 2018

Third iteration

At this point the code is ready to accept dependencies. The first step in that direction is to create a new type timenow.Timenow and have a field for the HTTP client:

// timenow.go

type Timenow struct {
	HttpClient *http.Client
}

Then I moved the timenow.Fetch function to timenow.Timenow, and let the implementation use the HttpClient field.

// timenow.go

func (t *Timenow) Execute() (string, error) {
	resp, err := t.HttpClient.Get(apiEndpoint)
	// ...
}

As a facility for consumers of this package, there is the timenow.New function that has the responsibility of constructing a new timenow.Timenow struct.

// timenow.go

func New(httpClient *http.Client) *Timenow {
	hc := httpClient

	if hc == nil {
		hc = &http.Client{Timeout: 10 * time.Second}
	}

	return &Timenow{
		HttpClient: hc,
	}
}

The timenow.New accepts a pointer to http.Client, so we can pass nil if we want to let the function itself to instantiate the dependency, or we can specify it.

// cmd/timenow.go
package main

import (
	"fmt"
	"os"

	"github.com/jodosha/timenow"
)

func main() {
	// Let the function to instantiate the dependency
	t := timenow.New(nil)

	now, err := t.Execute()
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}

	fmt.Println(now)
}

If the caller wants to inject the dependency, it must pass it as an argument to timenow.New:

// cmd/timenow.go

func main() {
	// Pass the dependency
	t := timenow.New(http.DefaultClient)
	// ...
}

Conclusion

Go doesn't provide tools or standard patterns for Dependency Injection, but the pattern can be easily implemented with a struct that holds all the dependencies needed for the job.

To see the full working example, please check this repository: https://github.com/jodosha/timenow.

Share on Twitter and Facebook

Luca Guidi's profile picture

Luca Guidi

Former astronaut, soccer player, superhero. All at the age of 10. For some reason now I write code.

We think domain management should be easy.
That's why we continue building DNSimple.

Try us free for 30 days
4.5 stars

4.3 out of 5 stars.

Based on Trustpilot.com and G2.com reviews.