12.8 Returning Concrete Types vs Interfaces: API Design Guidance
Right, let’s settle this. One of the most common, and frankly, most tedious debates in Go API design is whether to return concrete types (*MyStruct) or interfaces (MyInterface). You’ll find zealots on both sides, but the correct answer, as with most things in engineering, is a deeply unsatisfying “it depends.” But I’ll give you the tools to know what it depends on.
The core principle is this: Your function’s return type is a contract, a promise. The narrower the promise, the more freedom you have to change your implementation later without breaking the world.
The Case for Concrete Types
Start here. By default, return a concrete type (usually a pointer to a struct). Why? Because it’s honest. It’s the path of least commitment.
// This function makes no grand promises. It just gives you a *File.
func Open(name string) (*File, error) {
// ... implementation that returns a very specific struct
}
If you return *os.File, you’re saying, “Here’s a file. It has all the methods of *os.File.” That’s it. The glorious part? You can add methods to *os.File in a future library version without it being a breaking change for every single user. Their code continues to compile. This is your escape hatch for future enhancement.
The other massive win is that it’s usable without type assertions. The caller gets a fully-formed struct immediately. They can access its fields (if you exported them, you maniac), call its methods, and pass it around. It’s simple.
When to Return an Interface
You return an interface for precisely one good reason: when you want to, or need to, hide the implementation details completely. This is the power move.
Think about the http.Handler. You don’t get a *concreteServerType from http.ListenAndServe. You provide an interface. Why? Because the HTTP package truly does not care what your implementation is. It just needs to know that it can call ServeHTTP on it. This allows for an insane amount of flexibility: your handler could be a function adapter, a middleware chain, a router—anything.
You should return an interface when:
- You have multiple, swappable implementations. Think
io.Reader. You might return a*bytes.Reader, a*gzip.Reader, or a*strings.Readerbased on some condition. The caller shouldn’t care which one; they just want to read bytes. - You need to decouple for testing. This is the classic one. If your function returns a concrete
*DatabaseConnector, I’m stuck with it in my tests. If it returns aDBConnectorinterface with aQuerymethod, you can hand me a mock instead. But a word of caution: don’t define interfaces just for mocking. Let them emerge from a genuine need for abstraction. - The concrete type is a nightmarish giant. Sometimes you have a type with 30 methods, but the caller only needs to call 2 of them. Returning a narrow interface that describes just those two methods can be a act of mercy, simplifying the API surface. The
io.ReadCloseris a famous example—it’s just the two methods you almost always care about.
Here’s a practical example. Let’s say we’re building a simple data store:
// The concrete type. It's got everything, including a mutex we don't want users messing with.
type Store struct {
mu sync.RWMutex
data map[string]string
}
func (s *Store) Get(key string) string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.data[key]
}
func (s *Store) Set(key, value string) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[key] = value
}
// This is the narrow interface we'll expose.
type DataStore interface {
Get(key string) string
Set(key, value string)
}
// NewStore returns the interface, hiding the concrete struct and its sync primitives.
func NewStore() DataStore {
return &Store{
data: make(map[string]string),
}
}
Now, the user’s code interacts only with the DataStore interface. Next week, if I want to change the implementation to use a sharded map or boltDB underneath, I can do so. The user’s code doesn’t know and doesn’t care. I’ve successfully hidden the implementation details.
The Major Pitfall: Premature Abstraction
This is where junior engineers get tripped up. They read “program to interfaces” and go nuts, defining an interface for every single concrete type in the same package and returning it everywhere.
// Don't do this. This is ceremony without purpose.
type MyThingInterface interface {
DoTheThing()
}
func NewMyThing() MyThingInterface { // WHY?!
return &MyThing{}
}
This is utterly pointless. You’ve created an interface that has exactly one implementation, defined in the same package. You’ve added cognitive overhead for anyone reading your code (“What other implementations of MyThingInterface are there?”) and gained absolutely nothing. The concrete type *MyThing was a perfectly fine promise. If you need an interface later, you can always extract it. Start concrete.
So, the rule of thumb: Return concrete types from your constructors and functions unless you have a specific, identified reason to return an interface. That reason is almost always “because I want to hide what’s actually going on under the hood.” Anything else is probably you over-engineering it. Now go write some code.