Skip to main content
Preslav Rachev

Image Credits: DALL-E

One of the frequent code smells I notice in Go projects (mine included) is the excessive use of prematurely derived interfaces. I think we can all blame classical software engineering for that. Many of us were brought into programming with this idea of tiered applications - with a set of HTTP handlers that call into some sort of business logic service, which in turn calls into a persistence layer, or multiple outbound layers, etc. As a software engineering student of the early 2000s, I was taught that this is how cohesion is achieved - this way, we’d be able to swap the implementation of, say, our database layer if we need to, and we’d write more granular unit tests through fine-grained mocks.

Thus, it is hardly surprising that every second tutorial on Go won’t hesitate to introduce interfaces (perhaps unintentionally), implying that interfaces are an excellent candidate to substitute direct dependencies:

type CustomerManager interface {
    CreateCustomer(name, email string) error
    UpdateCustomer(id int, name, email string) error
    DeleteCustomer(id int) error
    FindCustomerByID(id int) (*Customer, error)
    FindCustomersByName(name string) ([]*Customer, error)
    // ... potentially 20 many more methods related to customer management
}

Managing customers is only one of the many dimensions of our app. Suddenly, you end up adding a ProductManager, an OrderManager, a BalanceManager, and a bunch of other bloated interfaces because, hey, it’s going to make code more cohesive, right?

The same tutorial might even hint at how easy it is to test your code now using tools like gomock:

func TestSomeFunctionThatCreatesACustomer(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockCustomerManager := mocks.NewMockCustomerManager(ctrl)

    // Expectation: CreateCustomer is called with specific parameters and returns nil
    mockCustomerManager.EXPECT().
        CreateCustomer("John Doe", "john@example.com").
        Return(nil)

    // Now call a function that uses CustomerManager and performs CreateCustomer
    err := someFunctionThatCreatesCustomer(mockCustomerManager)
    if err != nil {
        t.Errorf("someFunctionThatCreatesCustomer failed: %v", err)
    }
}

The reality #

The truth is, by introducing interfaces between every two call points of your code, you make it multiple times harder to follow now, not easier. Bloated interfaces with methods that leak implementation details are fragile, and even the slightest change (and there will be many) will incur cascading changes across your code. And no, Go’s implicit interface nature won’t save you here - if our methods are tightly coupled to implementation details, we are screwed either way. Take FindCustomersByName for example. What if a customer no longer has a name field but first and last names? Or what if the method needs to return something other than a (*Customer, error) pair?

But that’s not the end of it. Of course, flaky interfaces will result in flaky mocks, and flaky mocks will result in flaky tests. A slight code change will trigger an interface contract to change, and as a result, our tests need to get adapted, too. I don’t know how you feel about it, but changing tests regularly makes me lose trust in their validity.

The solution - YAGNI #

Let’s be realistic - software rarely undergoes radical changes once in production. It usually gets extended (new functionality gets added, existing functionality remains intact) or replaced altogether. Unlike two decades ago, the cohesion bit has now shifted outside the scope of the single application and into the cluster of applications that form a software system these days. Go’s creators noticed and reflected that shifting paradigm in the language’s lean type extension features (lack of class-based inheritance).

This means that for the scope of a single software service, introducing multiple levels of abstraction through interfaces is likely not needed and is likely counter-productive and, perhaps, even error-prone.

And here comes people’s #1 question - how do we test database interactions in isolation? After all, that’s what the big books say is a good practice, right? Well, I want to challenge that. First, you are proving nothing by verifying whether some mocked database call has happened or not. Second, adding the abstraction layer’s flakiness, you will likely have to change your tests over time. Third, I believe that tests should cover every bit of code owned by the application, including the database - I mean, for a large majority of software, the database is the software itself - the rest being some access and business logic on top. Last but not least, if you use an old-school relational database like PostgreSQL, MySQL, or SQLite, setting one up for testing (even one that runs in memory) is a piece of cake these days. This is how popular Web frameworks like Ruby on Rails and Django have approached testing since the beginning of their existence.

Interfaces finally come to the rescue #

Of course, I am not against the idea of mocks in general. There are situations where we can’t spin a database for testing, or our software needs to communicate with 3rd party APIs beyond our control. This is where the true power of Go’s interfaces comes into play.

Before you get too excited, I still don’t mean the bloated interfaces full of implementation details we saw above. I mean small, general-purpose, generic (not to be confused with generics) because general-purpose problems are what Go interfaces are meant to solve. Take a look at some of the great examples in the go standard library, io.Reader, io.Writer, sql’s driver.Driver, driver.Conn, driver.Querier, http.Handler, http.HandlerFunc, or the http.RoundTripper used by HTTP clients to connect to external APIs. Do you see a common pattern here? All of these interfaces provide low-level IO extension points, and to a certain extent, that’s precisely the level of cohesion we need. Isolate our app’s inbound and outbound communication (IO) with the rest of the world, and we are all good.

While I’m not advocating mocking your SQL database, look at Data Dog’s go-sqlmock. It’s an excellent example of taking advantage of the standard library’s Driver interface, providing a way to intercept and validate SQL code without the presence of an actual database. Similarly, instead of mocking the 3rd-party API clients our app might be using, we can mock their transport layer - in simpler terms, provide our clients with a custom HTTP client (http.RoundTripper) that simulates responses returned by the 3rd-party API. In this way, we will be testing how the rest of our app uses the data provided by the external system and how this data is generated out of an HTTP response in the first place (an often overlooked aspect).

Tips to look for in solid interface contracts #

Before I let you go on with your day, here are a couple of heuristics that might help you figure out if your interface contract is solid or flaky:

  1. Do you really need that extra interface? Can an interface from the standard library or a 3rd-party package do this for you?
  2. Are you developing a library or an application? Interfaces are more likely to make use in library code than in applications.
  3. Interfaces must naturally appear out of a need, not be designed up-front.
  4. Aim for a broad contract. Ideally, one method is all you need. More methods force the consumer to know too much and will cause frequent cascading changes.
  5. The above applies to method arguments and results as well. Aim for a small number of args, ideally, based on primitives (int, string, etc.) or well-known types (io.Reader, io.Writer, error) from the standard library. Avoid relying on custom types (structs) when possible - this will reduce the reusability of your interface.
  6. Mocking is not a reason to introduce a custom interface.
  7. In the end, ask yourselves - how many different use cases do you realistically envision for this interface? If the answer is less than 3 (testing does not count), you are better off choosing one from the standard lib or not using an interface.

Have something to say? Join the discussion below 👇

Want to explore instead? Fly with the time capsule 🛸

You may also find these interesting