You write a function that returns a PostgresClient
Six months later, you need to support MySQL. Every function that called your original one now has to change. Or worse, you're writing a library and you return a concrete struct. The user imports your package and gets locked into your specific implementation. They can't swap it out for a mock in their tests. They can't extend it. You've painted them into a corner with a concrete type.
The fix isn't just about using interfaces. It's about who holds the power in your API design. Go has a community mantra that solves this: "Accept interfaces, return structs." It sounds like a rule without a reason until you look at the flow of data and control. The mantra splits the world into producers and consumers. Producers create values. Consumers use values. The rule tells each side exactly what to do to keep the code flexible.
The producer-consumer split
The function that returns a value decides what that value is. The function that accepts a value decides what it needs. If you return an interface, you're forcing the caller to deal with a contract you defined. You're also hiding the concrete implementation behind a wall. The caller can only use the methods in the interface. If they need something else, they're stuck.
If you accept a struct, you're locking the caller into a specific implementation they might not have. They must construct that exact struct and pass it in. They can't substitute a different type that behaves the same way.
Think of a power outlet. The wall has an outlet. It accepts any plug that fits the shape. The outlet doesn't care if the plug is from a lamp, a charger, or a toaster. The outlet accepts the interface. The lamp manufacturer returns a concrete lamp with a plug. The lamp doesn't return a "plug interface." It returns the lamp. You buy the lamp. You plug it in. The wall accepts the plug. The lamp returns the concrete device.
The producer returns the concrete thing. The consumer accepts the abstraction. This keeps both sides flexible. The producer can change the internal details of the lamp without breaking the plug. The consumer can swap the lamp for a toaster without changing the wall.
Minimal example
Here is a simple case. A function needs to write data. It doesn't care where the data goes. It could go to a file, a network stream, or a test buffer. The function accepts an interface. The code that creates the destination returns a struct.
// Writer defines the behavior needed to write bytes.
// The consumer defines this interface to express what it requires.
type Writer interface {
Write(p []byte) (n int, err error)
}
// File represents a concrete file on disk.
// The producer defines this struct to provide the implementation.
type File struct {
path string
}
// Write implements Writer for File.
// Go checks the method set at compile time. No implements keyword is needed.
func (f *File) Write(p []byte) (int, error) {
// Implementation details are hidden from the caller.
// The receiver name is a short letter matching the type.
return len(p), nil
}
// CreateFile returns a concrete struct.
// Returning the struct gives the caller full access to File methods.
// The caller decides to use this specific file.
func CreateFile(path string) *File {
return &File{path: path}
}
// Process accepts the interface.
// It works with any type that satisfies Writer, including mocks.
// The function doesn't care about the underlying implementation.
func Process(w Writer) error {
_, err := w.Write([]byte("hello"))
return err
}
The Process function accepts Writer. It can work with File, but it can also work with a NetworkStream or a MockWriter. The CreateFile function returns *File. The caller gets the concrete type. If the caller wants to use Process, they can pass the file. If the caller needs to do something specific to files, like close them, they can do that too because they have the struct.
How interfaces work under the hood
When you assign a struct to an interface, Go creates an interface value. Under the hood, this is a pair: the concrete type and a pointer to the data. The compiler checks at compile time that the struct has all the methods required by the interface. If you miss a method, the compiler rejects the code with a does not implement interface error. There is no magic. The interface is just a way to say "this value has these methods."
Go doesn't require you to declare that a struct implements an interface. If the methods match, it implements. This is called structural typing. It keeps interfaces lightweight. You can define an interface in one package and implement it in another without importing the interface package. The standard library uses this everywhere. io.Reader and io.Writer are defined in the io package. Packages like os and net return structs that implement these interfaces without importing io just to say "I implement Reader."
This design encourages small interfaces. The io package defines Reader with one method. It defines Writer with one method. If you need both, you compose them. You don't define a ReadWriteCloser interface with three methods and force everyone to implement all three. You accept io.Reader where you need reading. You accept io.Writer where you need writing.
Realistic example: notification service
You're building a notification service. You have an email sender and an SMS sender. You want a function that sends notifications. The function should work with any sender.
// Notifier defines how to send a message.
// The consumer defines this interface to express the required behavior.
type Notifier interface {
Send(to string, msg string) error
}
// EmailClient is a concrete implementation.
// The producer defines this struct to provide email functionality.
type EmailClient struct {
smtpHost string
}
// Send implements Notifier for EmailClient.
// The receiver name is a short letter matching the type.
func (e *EmailClient) Send(to string, msg string) error {
// Send email logic.
// Error handling follows the standard pattern.
return nil
}
// NewEmailClient returns the concrete client.
// The caller decides to use email and gets the full struct.
func NewEmailClient(host string) *EmailClient {
return &EmailClient{smtpHost: host}
}
// Notify accepts the interface.
// It can work with EmailClient, SmsClient, or a mock.
// The function is flexible because it accepts an interface.
func Notify(n Notifier, user string) error {
return n.Send(user, "Welcome!")
}
In tests, you can pass a mock notifier. The Notify function doesn't change. You only change the input. If Notify returned an interface, you'd be stuck with the interface type and couldn't access concrete methods if you needed them later. Returning the struct keeps the options open.
Pitfalls and compiler errors
Returning an interface is usually a mistake. It forces the caller to work with the interface. If the caller needs a method that isn't in the interface, they're stuck. They can't type assert easily if the interface doesn't expose the concrete type. You end up with code that does type assertions to get back to the struct, which defeats the purpose of the interface.
The compiler complains with a does not implement interface error if the struct is missing a method. For example, if you try to return a struct that doesn't have the Write method where a Writer is expected, you get cannot use struct value as interface value in return argument: struct does not implement interface (missing method Write). This error is clear. Fix the method signature or add the missing method.
Another pitfall is interface bloat. Don't define a SuperService interface with ten methods. Define small interfaces like Reader or Closer. If you accept a huge interface, you're coupling to too much behavior. The caller has to implement all those methods, even if the function only uses one. Small interfaces are easier to implement and easier to test.
Receiver names are usually one or two letters matching the type. (e *EmailClient) not (this *EmailClient). This keeps code concise. gofmt handles formatting. Don't argue about indentation. Let the tool decide. Most editors run gofmt on save. The community expects consistent formatting.
Decision matrix
Use a struct return when you are creating the value and want the caller to have access to all its methods.
Use an interface parameter when you need specific behavior and want to allow the caller to provide any implementation.
Use a concrete type parameter when the function relies on fields or methods that are unique to that type and cannot be abstracted.
Use an interface return only when you need to hide the implementation details from the caller, such as in a factory function where the concrete type is internal to the package.
Use a struct return when you are building a library and want users to have the flexibility to extend or inspect the value.
Use an interface parameter when you are writing a function that should work with multiple types, like io.Copy which accepts io.Reader and io.Writer.
Where to go next
- How to Manage Environment-Specific Configuration for Deploys
- How to Use GoReleaser to Automate Go Releases
- How do goroutines work in Go
Interfaces are for the caller. Structs are for the implementer. Don't return interfaces unless you have a compelling reason. Small interfaces win. Trust the type system.