Creating common interfaces in Golang
If your API can have multiple implementations, follow this process:
- Try to write a vanilla Go interface that supports all implementations.
- Do not use
interface{}
types — #todo consider generics for this - If your interface uses a custom type, extract it into a separate package.
- Do not use
- 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.