Creating common interfaces in Golang

If your API can have multiple implementations, follow this process:

  1. Try to write a vanilla Go interface that supports all implementations.
    1. Do not use interface{} types — #todo consider generics for this
    2. If your interface uses a custom type, extract it into a separate package.
  2. If you cannot come up with a clean interface, don’t do it. Instead, introduce an educational pattern for everyone to follow, to write alternative implementations.

Do not perform Golang-gymnastics to achieve generic code. Such tricks end up with code that’s hard to follow.

One of the answers in the linked StackOverflow post was marked for deletion by the community bot. I think it’s the most insightful answer, and it’s a shame that it’s hidden now, so I preserved it here instead:

The Deleted Answer by VonC

After discussing a parallel case in Problem with define interface where it is used” principle in Golang, I believe that, in your case, the essence of the problem seems to be this: when a function/method in a producer package expects an interface as an argument, that creates a conundrum when we try to create an interface in the consumer package to abstract away the producer.

That is because we either have to reference the producer’s interface in our consumer interface (violating the principle of defining interfaces where they are used), or we have to create a new interface in the consumer package that might not be fully compatible with the producer’s interface (resulting in compile errors).

That is a situation where the define interfaces where they are used guideline (as discussed in Effective Go / Interfaces and other types / Generality) comes into tension with the goal of reducing coupling.

In such cases, it might be useful to think about why we are trying to reduce coupling. In general, we want to reduce coupling to increase flexibility: the less our consumer package knows about the producer package, the easier it is to swap out the producer package for a different implementation.
But at the same time, our consumer package needs to know enough about the producer package to use it correctly.

See also, as illustration (in different languages, but with a similar idea) Why coupling is always bad / Cohesion vs. coupling from Vidar Hokstad.


One possible solution, as you have noted, is to define a shared interface in a third package. That reduces the coupling between the consumer and producer packages, since they both depend on the third package, but not on each other. However, this solution can feel like overkill, especially for small interfaces. Plus, it introduces a new dependency, which can increase complexity.

Given the chef and fruit package structure, one approach could involve creating a common interface in a shared package. Let’s name it kitchen.

package kitchen

// Define the Fruit interface in a shared package.
type Fruit interface {
    Color() string
    Taste() string
}

Then in your chef package, you reference this Fruit interface:

package chef

import "yourproject/kitchen"

type Chef struct{}

func (c Chef) Cut(f kitchen.Fruit) error {
    // Do the cutting...
    return nil
}

Now, in your consumer package:

package consumer

import "yourproject/kitchen"

// Define an interface specific to the needs of your package.
type myRequirements interface {
  Cut(kitchen.Fruit) error
}

That approach allows the consumer package to define an interface to match its needs without directly depending on the chef package. However, it does rely on a common understanding of what a Fruit is, which is defined in the shared kitchen package.

Keep in mind, this approach introduces a dependency on the kitchen package, but if Fruit is a fundamental concept to your application that many packages will need to understand, it might be reasonable to define it in a common place.


In practice, the decision might come down to practical considerations. If the producer’s interface is stable and unlikely to change, and if the consumer package is already closely tied to the producer package in other ways, then it might make sense to simply reference the producer’s interface in the consumer package, even though this increases coupling.

On the other hand, if the producer’s interface is likely to change, or if you want to preserve the flexibility to swap out the producer package for a different implementation, then defining a shared interface in a third package might be the better choice, despite the added complexity.

In that last case, you would still have the chef package, but you would also have to deal with another package, butler, which does similar tasks but might not comply with the same interface as chef.

First, you have the Fruit interface in the kitchen package like before:

package kitchen

// Define the Fruit interface in a shared package.
type Fruit interface {
    Color() string
    Taste() string
}

And in the chef package:

package chef

import "yourproject/kitchen"

type Chef struct{}

func (c Chef) Cut(f kitchen.Fruit) error {
    // Do the cutting...
    return nil
}

Now, let’s imagine a new package butler which has a similar Prepare method:

package butler

import "yourproject/kitchen"

type Butler struct{}

func (b Butler) Prepare(f kitchen.Fruit) error {
    // Do some preparing...
    return nil
}

In your consumer code, you can now define two separate interfaces, one for each package’s requirements:

package consumer

import "yourproject/kitchen"

// Define an interface for the chef package
type chefRequirements interface {
  Cut(kitchen.Fruit) error
}

// And another for the butler package
type butlerRequirements interface {
  Prepare(kitchen.Fruit) error
}

That means you have defined separate interfaces for the chef and butler, each tailored to the consumer’s use case for each package.
By doing this, you retain the flexibility to use either the chef or butler packages (or even both) without tightly coupling your code to either one. You have moved the shared interface (Fruit) to a third package, kitchen, and you are using that in your consumer’s interfaces.

That example demonstrates the flexibility of Go’s interface system and how it can be used to decouple packages and support multiple implementations of a concept. But that does add some complexity, in exchange for a good deal of flexibility in return.


In other words, there is no hard and fast resolution to this dilemma: the best solution depends on the specific context and requirements of your project. Both approaches have trade-offs, and the right choice depends on which trade-offs are more acceptable in your situation.

It is also worth noting that this is a fairly niche problem that you are unlikely to encounter often in day-to-day Go programming. In many cases, you can structure your code in such a way that this issue does not arise. But when it does, it is a reminder that guidelines are just that—guides, not rigid rules, and it is up to us as developers to make the final judgment based on our understanding of the specific problem and the broader context in which it exists.



Date
October 30, 2022