DI & IoC in Go - How and Why
What is DI (Dependency Injection) and
IoC (Inversion of Control)?
And, more important, what is it good for?
In this article I'm going to show what DI (Dependency Injection) and IoC (Inversion of Control) are and, more importantly, what they're good for. As an example, I'm using a REST API client for Monibot. Monibot is a web-app monitoring solution for developers. Among many other things, developers can send custom application metrics to Monibot, more on https://monibot.io. This blog post is inspired by a recent question on Reddit: How to handle DI in Golang? Please note: This article is for developers. It uses many code samples and goes into technical details. I warned you. Let's Go:
A simple Client implementation
We start with a simple Client implementation that allows callers to access the Monibot REST API. More specifically, to send a metric value. The Client implementation may look like this:
package monibot
type Client struct {
}
func NewClient() *Client {
return &Client{}
}
func (c *Client) PostMetricValue(value int) {
body := fmt.Sprintf("value=%d", value)
http.Post("https://monibot.io/api/metric", []byte(body))
}
Here we have a Client that provides a PostMetricValue method. This method is used to upload a metric value to Monibot. The user of our library might use it like this:
import "monibot"
func main() {
// initialize a API client
client := monibot.NewClient()
// send a metric value
client.PostMetricValue(42)
}
Dependency Injection
Now assume we want to unit-test our Client. How can we test our Client when all HTTP-sending code is hardwired? For each test run, we would need a 'real' HTTP server that answers all requests we send to it. No way! We can do better: Let's make the HTTP handling a 'dependency'. Let's invent a 'Transport' interface:
package monibot
// A Transport transports requests.
type Transport interface {
Post(url string, body []byte)
}
Lets also invent a concrete Transport that uses HTTP as communication protocol:
package monibot
// HTTPTransport is a Transport that uses the HTTP protocol for transporting requests.
type HTTPTransport struct {
}
func (t HTTPTransport) Post(url string, data []byte) {
http.Post(url, data)
}
And let's rewrite the Client so that it 'depends' on a Transport:
package monibot
type Client struct {
transport Transport
}
func NewClient(transport Transport) *Client {
return &Client{transport}
}
func (c *Client) PostMetricValue(value int) {
body := fmt.Sprintf("value=%d", value)
c.transport.Post("https://monibot.io/api/metric", []byte(body))
}
Now the Client forwards requests to its Transport dependency. The Transport (a dependency of the Client) is 'injected' into the Client when the Client is created: DI - Dependency Injection. The caller initializes a Client like so:
import "monibot"
func main() {
// initialize a API client
var transport monibot.HTTPTransport
client := monibot.NewClient(transport)
// send a metric value
client.PostMetricValue(42)
}
Unit Testing
Now we can write a unit test that uses a 'fake' Transport:
// TestPostMetricValue makes sure that the client sends the
// correct POST request to the REST API.
func TestPostMetricValue(t *testing.T) {
transport := &fakeTransport{}
client := NewClient(transport)
client.PostMetricValue(42)
if len(transport.calls) != 1 {
t.Fatal("want 1 transport call but was %d", len(transport.calls))
}
if transport.calls[0] != "POST https://monibot.io/api/metric, body=\"value=42\"" {
t.Fatal("wrong transport call %q", transport.calls[0])
}
}
// A fakeTransport is a Transport used in unit tests.
type fakeTransport struct {
calls []string
}
func (f *fakeTransport) Post(url string, body []byte) {
f.calls = append(f.calls, fmt.Sprintf("POST %v, body=%q", url, string(body)))
}
More Transport Functions
Now let's assume other parts of our library, which also use the Transport facility, need more HTTP methods than POST. For them, we have to extend our Transport interface:
package monibot
// A Transport transports requests.
type Transport interface {
Get(url string) []byte // added because health-monitor needs it
Post(url string, body []byte)
Delete(url string) // added because resource-monitor needs it
}
Now we have a problem. The compiler complains that our fakeTransport does not fulfill the Transport interface anymore. So Let's fix it by adding the missing functions:
// A fakeTransport is a Transport used in unit tests.
type fakeTransport struct {
calls []string
}
func (f *fakeTransport) Get(url string) []byte {
panic("not used")
}
func (f *fakeTransport) Post(url string, body []byte) {
f.calls = append(f.calls, fmt.Sprintf("POST %v, body=%q", url, string(body)))
}
func (f *fakeTransport) Delete(url string) {
panic("not used")
}
What have we done? Since we do not need the new functions Get() and Delete() in our unit test, we panic if they are called. Here is a problem: With every new function in Transport, we break our existing fakeTransport implementation. With large code bases, that leads to maintenance nightmares. Can we do better?
IoC - Inversion of Control
The problem is that our Client (and accompanying unit test) depends on a type that they do not control. In this case, it's the Transport interface. To solve the problem, let's invert the control by introducing an unexported interface that declares only what's needed for our Client:
package monibot
// A clientTransport transports requests of a Client.
type clientTransport interface {
Post(url string, body []byte)
}
type Client struct {
transport clientTransport
}
func NewClient(transport clientTransport) *Client {
return &Client{transport}
}
func (c *Client) PostMetricValue(value int) {
body := fmt.Sprintf("value=%d", value)
c.transport.Post("https://monibot.io/api/metric", []byte(body))
}
Now let's change our unit test to use a fake clientTransport:
// TestPostMetricValue makes sure that the client sends the
// correct POST request to the REST API.
func TestPostMetricValue(t *testing.T) {
transport := &fakeTransport{}
client := NewClient(transport)
client.PostMetricValue(42)
if len(f.calls) != 1 {
t.Fatal("want 1 transport call but was %d", len(f.calls))
}
if f.calls[0] != "POST https://monibot.io/api/metric, body=\"value=42\"" {
t.Fatal("wrong transport call %q", f.calls[0])
}
}
// A fakeTransport is a clientTransport used in unit tests.
type fakeTransport struct {
calls []string
}
func (f *fakeTransport) Post(url string, body []byte) {
f.calls = append(f.calls, fmt.Sprintf("POST %v, body=%q", url, string(body)))
}
Thanks to Go's implicit interface implementation (call it 'duck typing' if you want), nothing changes for the user of our library:
import "monibot"
func main() {
// initialize a API client
var transport monibot.HTTPTransport
client := monibot.NewClient(transport)
// send a metric value
client.PostMetricValue(42)
}
Revisiting Transport
If we make IoC the norm (as we should), there is no need to have an exported Transport interface anymore. Why? Because every potential user of Transport would be better off if it declared its own unexported interface, as we did with 'clientTransport'.
Don't export interfaces. Export concrete implementations. If consumers need an interface, let them define it in their own scope.
Summary
In this article, I have shown how, and why, to use DI and IoC in Go. Using DI/IoC correctly can lead to code that is better to test and better to maintain, especially as the code base grows. While the code samples are in Go, the principles described here apply also to other programming languages.
If you want to check out the Monibot REST API client, look here:
https://github.com/cvilsmeier/monibot-go
Subscribe to our newsletter. We will keep you informed about updates and news, approx. once per month. Do not worry, you can unsubscribe at any time.