Using Structs to Facilitate Dependency Injection with Go
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.
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.
4.3 out of 5 stars.
Based on Trustpilot.com and G2.com reviews.