
One interesting thing about Go is that it lacks and explicit implements keyword for interfaces. If you want to implement an interface, there’s no extra step. As long as function signatures match the interface, or function type declaration in those cases, the interface is implemented. The same could be said of Python protocols too.
Coming from a background of PHP, Python, JavaScript it’s easy to overlook that interfaces in Go can be anywhere. Because there’s no implements keyword.
For example, let’s take a function that depends on a net/http client, specifically the Do method.
import (
"context"
"net/http"
"io"
)
func MakeImportantRequst(ctx context.Context, client *http.Client) (string, error) {
req, err := http.NewRequestWithContext(
ctx,
"GET",
"https://example.com",
nil,
)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(body), nil
}
In other languages we could not reach into the net/http code and make it implement an interface so we can depend on an abstraction. We don’t have that concern in Go. We can just make our own interface and depend on that:
type RequestDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func MakeImportantRequst(ctx context.Context, client RequestDoer) (string, error) {
// ...
}
No we have dependency inversion. We do not depend on the concrete client anymore, but note that this is a leaky abstracton: it still has details about net/http in it.
We could now make a stub client to test this logic:
type StubRequestDoer struct {
Response *http.Response
Error error
}
func (s *StubRequestDoer) Do(req *http.Request) (*http.Response, error) {
return s.Response, s.Error
}
Though one could also stub or make a bespoke implementation of RoundTripper and pass that to a custom Client instance to accomplish the same thing without the above abstraction.
The above would work fine with a function type as well:
type MakeRequest func (*http.Request) (*http.Response, error)
func MakeImportantRequst(ctx context.Context, client MakeRequest) (string, error) {
// ...
}
When Should One Do This?
As always: it depends.
I like to do this when I need to test stuff that I can’t really cover well with integration testing. For example, I was recently working with AWS Athena, and took the AWS SDK’s advice to define some interfaces for operations I needed, then wrote some bespoke test doubles to get better test coverage.
The above need sprung up on demand. I did not start with the client-side interface, but instead they emerged as the code I was writing created enough design pressure for me to need them. I think this is a good strategy in general: wait until you need things, then write them.
This tends to work well for hiding implementations in third party libraries, but creates leaky abstractions like the net/http client above. It does not replace the idea of hexagonal architecture but maybe makes the edges easier to more thoroughly test. But those edges still need real integration tests!