Interface Composition in Go

Original author: Rafael Jesus
  • Transfer
  • Tutorial
One of the most enjoyable Go concepts for me is the ability to compose interfaces. In this article, we will analyze a small example of using this feature of the language. To do this, imagine a hypothetical scenario in which two structures process user data and perform http requests.

type (
        // Структура отвечает за синхронизацию пользователей
	Sync struct {
		client HTTPClient
	}
)
// возвращает сконфигурированный экземпляр Sync
func NewSync(hc HTTPClient) *Sync {
	return &Sync{hc}
}
// Упрощенный код синхронизации пользователей со сторонней системой
func (s *Sync) Sync(user *User) error {
        res, err := s.client.Post(syncURL, "application/json", body)
        // обработка с res и err
        return err
}

type (
        // Структура отвечает за хранение пользовательских данных
        Store struct {
		client HTTPClient
	}
)
// возвращает сконфигурированный экземпляр Store
func NewStore(hc HTTPClient) *Store {
	return &Store{hc}
}
// Упрощенный код работы с данными пользователя
func (s *Store) Store(user *User) error {
	res, err := s.client.Get(userResource)
        // обработка с res и err
	res, err = s.client.Post(usersURL, "application/json", body)
        // обработка с res и err
	return err
}

The Sync and Store structures are responsible for operations with users in our system. In order for them to perform http requests, they need to pass a structure that satisfies the HTTPClient interface . Here is what he is:

type (
        // обертка вокруг http для всего приложения
        HTTPClient interface {
                // выполняет POST-запрос
	        Post(url, contentType string, body io.Reader) (*http.Response, error)
                // выполняет GET-запрос
                Get(url string) (*http.Response, error)
	}
)

So, we have two structures, each does one thing and does it well, and both of them depend on only one argument-interface. It looks like easy-to-test code, because all we need to do is create a stub for the HTTPClient interface . The unit test for Sync can be implemented as follows:

func TestUserSync(t *testing.T) {
	client := new(HTTPClientMock)
	client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) {
		// check if args are the expected
		return &http.Response{StatusCode: http.StatusOK}, nil
	}
	syncer := NewSync(client)
	u := NewUser("foo@mail.com", "de")
	if err := syncer.Sync(u); err != nil {
		t.Fatalf("failed to sync user: %v", err)
	}
	if !client.PostInvoked {
		t.Fatal("expected client.Post() to be invoked")
	}
}
type (
	HTTPClientMock struct {
		PostInvoked bool
		PostFunc    func(url, contentType string, body io.Reader) (*http.Response, error)
    		GetInvoked bool
		GetFunc    func(url string) (*http.Response, error)
	}
)
func (m *HTTPClientMock) Post(url, contentType string, body io.Reader) (*http.Response, error) {
	m.PostInvoked = true
	return m.PostFunc(url, contentType, body)
}
func (m *HTTPClientMock) Get(url string) (*http.Response, error) { return nil, nil}

This test works great, but you should pay attention to the fact that Sync does not use the Get method from the HTTPClient interface .
Clients should not depend on methods that they do not use. Robert Martin
Also, if you want to add a new method to HTTPClient , you will also have to add it to the HTTPClientMock stub , which degrades the readability of the code and complicates its testing. Even if you simply change the signature of the Get method , it will still affect the test for the Sync structure , even though this method is not used. Such dependencies should be eliminated.

In our example, we need to implement only two methods to stub the HTTPClient interface . But imagine that your hypothetical handler should receive messages from the queue and save them to the database:

type (
	AMQPHandler struct {
		repository Repository
	}
	Repository interface {
		Add(user *User) error
		FindByID(ID string) (*User, error)
		FindByEmail(email string) (*User, error)
		FindByCountry(country string) (*User, error)
		FindByEmailAndCountry(country string) (*User, error)
		Search(...CriteriaOption) ([]*User, error)
		Remove(ID string) error
		// и еще
		// и еще
		// и еще
		// ...
	}
)
func NewAMQPHandler(r Repository) *AMQPHandler {
	return &AMQPHandler{r}
}
func (h *AMQPHandler) Handle(body []byte) error {
	// сохранение пользователя
	if err := h.repository.Add(user); err != nil {
		return err
	}
	return nil
}

To save user data to the AMQPHandler database, you only need the Add method , but, as you probably guessed, the stub of the Repository interface for testing will look threatening:

type (
        RepositoryMock struct {
                AddInvoked bool
	}
)
func (r *Repository) Add(u *User) error {
        r.AddInvoked = true
        return nil
}
func (r *Repository) FindByID(ID string) (*User, error) { return nil }
func (r *Repository) FindByEmail(email string) (*User, error) { return nil }
func (r *Repository) FindByCountry(country string) (*User, error) { return nil }
func (r *Repository) FindByEmailAndCountry(email, country string) (*User, error) { return nil }
func (r *Repository) Search(...CriteriaOption) ([]*User, error) { return nil, nil }
func (r *Repository) Remove(ID string) error { return nil }

Due to a similar error in the application design, we have no other choice how to implement all methods of the Repository interface each time . But according to Go's philosophy, interfaces should usually be small, consisting of one or two methods. In this light, the implementation of the Repository seems completely redundant.
The larger the interface, the weaker the abstraction. Rob Pike
Let's go back to the user management code, both the Post and Get methods are needed only for saving data ( Store ), and only the Post method is enough for synchronization . Let's fix the Sync implementation with this in mind:

type (
	 // Структура отвечает за синхронизацию пользователей
	Sync struct {
		client HTTPPoster
	}
)
// возвращает сконфигурированный экземпляр Sync
func NewSync(hc HTTPPoster) *Sync {
	return &Sync{hc}
}
// Упрощенный код синхронизации пользователей со сторонней системой
func (s *Sync) Sync(user *User) error {
	res, err := s.client.Post(syncURL, "application/json", body)
        // обработка с res и err
        return err
}

func TestUserSync(t *testing.T) {
	client := new(HTTPPosterMock)
	client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) {
		// assert the arguments are the expected
		return &http.Response{StatusCode: http.StatusOK}, nil
	}
	syncer := NewSync(client)
	u := NewUser("foo@mail.com", "de")
	if err := syncer.Sync(u); err != nil {
		t.Fatalf("failed to sync user: %v", err)
	}
	if !client.PostInvoked {
		t.Fatal("expected client.Post() to be invoked")
	}
}
type (
	HTTPPosterMock struct {
		PostInvoked bool
		PostFunc    func(url, contentType string, body io.Reader) (*http.Response, error)
	}
)
func (m *HTTPPosterMock) Post(url, contentType string, body io.Reader) (*http.Response, error) {
	m.PostInvoked = true
	return m.PostFunc(url, contentType, body)
}

Now we do not need to deal with the redundant HTTPClient interface , this approach simplifies testing and avoids unnecessary dependencies. And also, the purpose of the argument for the NewSync constructor has become much clearer.

Now let's see what the test for Store might look like , using both methods from HTTPClient :

func TestUserStore(t *testing.T) {
	client := new(HTTPClientMock)
	client.PostFunc = func(url, contentType string, body io.Reader) (*http.Response, error) {
		// assertion omitted
		return &http.Response{StatusCode: http.StatusOK}, nil
	}
	client.GetFunc = func(url string) (*http.Response, error) {
		// assertion omitted
		return &http.Response{StatusCode: http.StatusOK}, nil
	}
	storer := NewStore(client)
	u := NewUser("foo@mail.com", "de")
	if err := storer.Store(u); err != nil {
		t.Fatalf("failed to store user: %v", err)
	}
	if !client.PostInvoked {
		t.Fatal("expected client.Post() to be invoked")
	}
	if !client.GetInvoked {
		t.Fatal("expected client.Get() to be invoked")
	}
}
type (
	HTTPClientMock struct {
		HTTPPosterMock
		HTTPGetterMock
	}
	HTTPPosterMock struct {
		PostInvoked bool
		PostFunc    func(url, contentType string, body io.Reader) (*http.Response, error)
	}
	HTTPGetterMock struct {
		GetInvoked bool
		GetFunc    func(url string) (*http.Response, error)
	}
)
func (m *HTTPPosterMock) Post(url, contentType string, body io.Reader) (*http.Response, error) {
	m.PostInvoked = true
	return m.PostFunc(url, contentType, body)
}
func (m *HTTPGetterMock) Get(url string) (*http.Response, error) {
	m.GetInvoked = true
	return m.GetFunc(url)
}

Honestly, I did not invent such an approach. This can be seen in the Go standard library, io.ReadWriter illustrates well the principle of interface composition:

type ReadWriter interface {
        Reader
        Writer
}

This way of organizing the interfaces makes the dependencies in the code more explicit.

An astute reader probably caught a hint of TDD in my example. Indeed, without unit tests it is difficult to achieve such a design on the first try. It is also worth noting the lack of external dependencies in the tests, this approach I spied on Ben Johnson .

Perhaps you are curious about what the HTTPClient implementation will look like ?

type (
	// обертка для http-запросов
	HTTPClient struct {
		req *Request
	}
	// структура для представления http-запроса
	Request struct{}
)
// возвращает сконфигурированный HTTPClient
func New(r *Request) *HTTPClient {
	return &HTTPClient{r}
}
// выполняет Get-запрос
func (c *HTTPClient) Get(url string) (*http.Response, error) {
	return c.req.Do(http.MethodGet, url, "application/json", nil)
}
// выполняет Post-запрос
func (c *HTTPClient) Post(url, contentType string, body io.Reader) (*http.Response, error) {
	return c.req.Do(http.MethodPost, url, contentType, body)
}
// выполняет http-запрос
func (r *Request) Do(method, url, contentType string, body io.Reader) (*http.Response, error) {
	req, err := http.NewRequest(method, url, body)
	if err != nil {
		return nil, fmt.Errorf("failed to create request %v: ", err)
	}
	req.Header.Set("Content-Type", contentType)
	return http.DefaultClient.Do(req)
}

It’s as simple as simple - just implement the methods for Post and Get . Note that the constructor does not return an interface and a specific type; this approach is recommended in Go. And the interface must be declared in the consumer packet, which will use HTTPClient . In our case, you can call the user package :

type (
	// Структура для представления данных пользователя
	User struct {
		Email   string `json:"email"`
		Country string `json:"country"`
	}
	// композиция интерфейсов
	HTTPClient interface {
		HTTPGetter
		HTTPPoster
	}
	// Интерфейс для Post-запросов
	HTTPPoster interface {
		Post(url, contentType string, body io.Reader) (*http.Response, error)
	}
	// Интерфейс для Get-запросов
	HTTPGetter interface {
		Get(url string) (*http.Response, error)
	}
)

And, in the end, put it all together in main.go

func main() {
	req := new(httpclient.Request)
	client := httpclient.New(req)
	_ = user.NewSync(client)
	_ = user.NewStore(client)
	// работа с Sync и Store
}

I hope this example helps you get started using the principle of interface separation to write more idiomatic Go code that is easy to test and has explicit dependencies. In the next article, I will add failover logic and resubmission to the HTTPClient , stay connected.

Full source code for implementing the example .

Special thanks to my friends Bastian and Felipe for reviewing this article.

Also popular now: