What "Accept Interfaces, Return Structs" Means and Why

Accept interfaces as parameters for flexibility and return concrete structs to hide implementation details and maintain API stability.

The contract and the reality

You are building a notification service. You write a function that takes a SlackClient to send alerts to a channel. The code works. Six months later, the company decides to also send alerts to PagerDuty. You update the function to take a PagerDutyClient. Then you need to support both based on configuration. Suddenly your function signature is a mess, or you are passing a giant config struct that knows too much about every possible destination. The code is brittle. Every time the implementation changes, the API changes, and you have to recompile every caller.

Go has a mantra for this: "Accept interfaces, return structs." It sounds like a rule from a style guide, but it is actually a survival mechanism for maintainable code. It keeps your functions flexible enough to handle new requirements without changing signatures, while keeping your constructors honest about what they produce.

What the mantra actually means

The mantra splits the world into two roles. When you write a function, you decide what you need. You usually need behavior, not a specific type. An interface describes behavior. A struct holds data and implementation. If you accept an interface, you say "I don't care what you are, as long as you can do this." If you return a struct, you say "Here is the thing I built. You can use it, but I own the details."

Think of a universal remote control. The remote has buttons for power, volume, and input. It does not care if the device is a TV, a soundbar, or a cable box. As long as the device responds to those signals, the remote works. The remote accepts the interface. The TV is the struct. You return the TV in the box, but you interact with it via the remote.

In Go, this pattern decouples the caller from the implementation. The function that accepts the interface can work with any type that satisfies the contract. The function that returns the struct gives the caller the full object, including fields and methods that go beyond the minimal contract. This keeps the API stable. You can swap implementations, add mocks for testing, or extend functionality without breaking the code that uses your library.

Minimal example

Here is the pattern in its simplest form. Define an interface for the behavior you need, implement it on a struct, accept the interface in functions, and return the struct from constructors.

package main

import "fmt"

// Describer captures the single behavior this module cares about.
// Any type with a Describe() method satisfies this automatically.
type Describer interface {
	Describe() string
}

// User holds the actual data.
type User struct {
	Name string
	Role string
}

// Describe implements Describer for User.
// Go interfaces are satisfied implicitly; no "implements" keyword needed.
func (u User) Describe() string {
	return fmt.Sprintf("%s (%s)", u.Name, u.Role)
}

// PrintDescription accepts Describer, not User.
// This allows passing User, Admin, or a mock type later without changing the signature.
func PrintDescription(d Describer) {
	// The function only calls Describe. It does not know about Name or Role.
	fmt.Println(d.Describe())
}

// NewUser returns a concrete User struct.
// Callers get the full type with all its fields and methods.
func NewUser(name, role string) User {
	return User{Name: name, Role: role}
}

func main() {
	u := NewUser("Alice", "admin")
	PrintDescription(u)
}

The function PrintDescription accepts Describer. It does not depend on User. You could pass a Device struct or a MockDescriber without touching PrintDescription. The function NewUser returns User. The caller gets the struct and can access Name and Role if needed. If NewUser returned Describer, the caller would lose access to those fields.

How the compiler and runtime handle this

Go interfaces are small data structures. An interface value holds two pointers: one to the concrete value and one to the type information, often called the itable. When you pass a User to PrintDescription, the compiler creates an interface value. It copies the data and links the type. The function calls Describe via the interface table. This is dynamic dispatch. It is slightly slower than a direct call, but the overhead is negligible for most code. The compiler optimizes interface calls aggressively when it can prove the concrete type.

Go does not require you to declare that a struct implements an interface. If the methods match, it works. This is called implicit satisfaction. It means you can add interface implementations to types in other packages without modifying those packages. The standard library uses this everywhere. io.Reader is satisfied by *os.File, bytes.Buffer, strings.Reader, and *net.Conn without any of those types importing io to declare a relationship. This keeps coupling low and allows libraries to compose naturally.

Convention aside: receiver names are usually one or two letters matching the type, like (u User) or (m *Mailer). Do not use this or self. The receiver name is part of the method signature in the eyes of the tooling, and short names keep the code clean.

Realistic scenario: dependency injection

Here is how this looks in a real service. You have a UserService that needs to send emails. You define a Mailer interface. The service accepts Mailer. You can inject SMTPMailer in production and MockMailer in tests.

package service

import "context"

// Mailer defines the contract for sending emails.
// The service only needs to know how to send, not how SMTP works.
type Mailer interface {
	Send(ctx context.Context, to string, body string) error
}

// SMTPMailer is the production implementation.
type SMTPMailer struct {
	Host string
	Port int
}

// Send implements Mailer using SMTP.
// Value receiver is fine since Host and Port are immutable during send.
func (m SMTPMailer) Send(ctx context.Context, to, body string) error {
	// Connect and send via SMTP...
	return nil
}

// MockMailer records calls for unit tests.
// Pointer receiver allows the mock to accumulate state.
type MockMailer struct {
	Sent []string
}

// Send implements Mailer by storing the recipient.
func (m *MockMailer) Send(ctx context.Context, to, body string) error {
	m.Sent = append(m.Sent, to)
	return nil
}

// UserService depends on Mailer, not SMTPMailer.
// This decouples the user logic from the email transport.
type UserService struct {
	mailer Mailer
}

// NewUserService returns a concrete struct.
// Callers construct the dependency and inject it.
func NewUserService(m Mailer) *UserService {
	return &UserService{mailer: m}
}

// Register triggers an email.
// If you swap the mailer implementation, Register does not change.
func (s *UserService) Register(ctx context.Context, email string) error {
	return s.mailer.Send(ctx, email, "Welcome!")
}

The UserService depends on Mailer. In production, you construct SMTPMailer and pass it to NewUserService. In tests, you construct MockMailer, pass it, and check the Sent slice afterward. The service code never changes. This is dependency injection, and it works because the service accepts an interface.

Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context must respect cancellation and deadlines. If the context is done, the function should return early. This is plumbing. Run it through every long-lived call site.

Pitfalls and traps

The mantra is a heuristic, not a law. Misapplying it leads to interface pollution and subtle bugs.

Do not define an interface for every struct. If you have a User and no other type needs to behave like a User, do not create a Userer interface. Define interfaces where they are used, not where they are implemented. If you define the interface in the same package as the implementation, you couple the package to the interface. Define the interface in the package that consumes it. This keeps the dependency direction correct. The consumer defines the contract; the producer satisfies it.

The compiler rejects code with cannot use u (variable of type User) as Describer value in argument: User does not implement Describer (missing method Describe) if methods are missing. This error is helpful. It catches mismatches at compile time.

Nil interfaces are a common trap. An interface value is a pair: type and value. If you assign a nil pointer to an interface, the interface holds the type but the value is nil. The interface itself is not nil.

package main

import "fmt"

type Stringer interface {
	String() string
}

type Widget struct {
	Label string
}

func (w Widget) String() string {
	return w.Label
}

func main() {
	// w is a nil pointer.
	var w *Widget

	// s holds the type *Widget and a nil value.
	// s is NOT nil.
	var s Stringer = w

	if s == nil {
		fmt.Println("s is nil")
	} else {
		// This panics because the underlying value is nil.
		fmt.Println(s.String())
	}
}

The check s == nil fails because s has a type. The call to String() panics with runtime error: nil pointer dereference. Always check the concrete value, not the interface, when dealing with pointers. Or better, avoid returning nil pointers wrapped in interfaces. Return a nil interface only when the value is truly absent.

Interface conversion panics are another runtime risk. If you use a type assertion with a single return value, the program panics if the assertion fails.

// This panics if d is not a User.
u := d.(User)

Use the two-value form to check safely.

// This returns false instead of panicking.
u, ok := d.(User)
if !ok {
	// Handle the mismatch.
}

The compiler cannot catch interface mismatches at runtime. The panic happens when the code executes. Write tests that exercise the interface with different implementations to catch these early.

When to use interfaces and when to use structs

Use an interface when a function needs behavior that multiple types can provide, such as reading data, sending notifications, or comparing values. Use an interface when you need to mock a dependency for testing, or when you want to depend on behavior from a third-party package you cannot modify. Use an interface when the contract is small and focused, like io.Reader or fmt.Stringer.

Use a concrete struct when a function constructs a value and the caller needs access to the full type, including fields and methods beyond the minimal contract. Use a concrete struct when the implementation is the only one that makes sense, or when you want to expose the internal state for debugging or configuration. Use a concrete struct when returning from a constructor, because the caller should get the object they asked for.

Use a generic type parameter when you are writing a data structure or algorithm that works over many types and you want to preserve the concrete type for the caller. Generics avoid the overhead of interface dispatch and keep type safety. Use generics when the constraint is structural and you need to return the original type, like a Stack[T] that returns T.

Use a function type when the abstraction is a single operation without associated state, like a comparison function or a retry backoff strategy. Function types are lighter than interfaces and express intent clearly. Use func(a, b int) int instead of an interface with one method when there is no state to bundle.

Small interfaces. Concrete returns. Trust the type system to enforce contracts, but keep the contracts small enough to be useful.

Where to go next