The Problem Is Never Just "It Doesn't Implement the Interface"
Go's interface satisfaction is structural and compile-time, which sounds safe until you're debugging why a concrete type that clearly has all the right methods refuses to satisfy an interface in a different package—or worse, satisfies it silently and then behaves incorrectly at runtime because pointer receivers were embedded into a value type that gets copied across a serialization boundary.
In large backend systems with multiple service layers, SDK packages, and AI integration adapters, the method set rules aren't a language curiosity. They're a load-bearing part of your API contract, and the failure modes are subtle enough to survive code review.
Method Sets: The Precise Rule
Go specifies method sets precisely. For a type T:
- The method set of
Tcontains all methods with receiverT. - The method set of
*Tcontains all methods with receiverTor*T.
For embedded types:
- If
Scontains an embedded fieldT, the method set ofSincludes the promoted methods ofT, and the method set of*Sincludes the promoted methods of bothTand*T. - If
Scontains an embedded field*T, both the method set ofSand*Sinclude the promoted methods of bothTand*T.
The asymmetry in that last rule is where backends get burned.
type Store interface {
Get(ctx context.Context, key string) ([]byte, error)
Put(ctx context.Context, key string, val []byte) error
Close() error
}
type RedisStore struct {
client *redis.Client
}
// Only pointer receiver methods exist
func (r *RedisStore) Get(ctx context.Context, key string) ([]byte, error) { ... }
func (r *RedisStore) Put(ctx context.Context, key string, val []byte) error { ... }
func (r *RedisStore) Close() error { ... }
type CachingLayer struct {
RedisStore // embedded by value, NOT pointer
local *sync.Map
}
CachingLayer embeds RedisStore by value. Its method set includes only the methods of RedisStore (receiver RedisStore), which is empty. *CachingLayer gets the promoted methods of *RedisStore. So *CachingLayer satisfies Store, but CachingLayer does not.
The compiler catches the direct assignment. But it doesn't catch this pattern, which appears in real service wiring:
func NewCachingLayer(r RedisStore) Store {
c := CachingLayer{RedisStore: r}
return c // compile error: CachingLayer does not implement Store
}
Change the return to return &c and it works. The failure mode is that many teams embed types by value when they should embed by pointer, learn the pattern by trial and error, but never document why—leaving the next engineer to rediscover it when they add a new method with a pointer receiver six months later.
Why This Surfaces at Package Boundaries
Within a single package, your editor and the compiler give immediate feedback. The problem metastasizes when the concrete type lives in an internal package, the interface lives in a public SDK package, and the wiring lives in a third service layer.
Consider a backend AI integration where you're wrapping an LLM provider behind a retrieval interface:
// sdk/retrieval/interface.go
package retrieval
type Retriever interface {
Query(ctx context.Context, q Query) (Results, error)
Embed(ctx context.Context, text string) ([]float32, error)
Health(ctx context.Context) error
}
// internal/openai/adapter.go
package openai
type Adapter struct {
cfg Config
client *http.Client
mu sync.Mutex
cache map[string][]float32
}
func (a *Adapter) Query(ctx context.Context, q retrieval.Query) (retrieval.Results, error) { ... }
func (a *Adapter) Embed(ctx context.Context, text string) ([]float32, error) { ... }
func (a *Adapter) Health(ctx context.Context) error { ... }
// service/wiring.go
package service
func wire(cfg Config) retrieval.Retriever {
adapter := openai.Adapter{cfg: cfg} // value, not pointer
return adapter // compile error
}
The error is caught. But consider a test double that embeds the real adapter:
type InstrumentedAdapter struct {
openai.Adapter // value embed
metrics *Metrics
}
func (i *InstrumentedAdapter) Query(ctx context.Context, q retrieval.Query) (retrieval.Results, error) {
defer i.metrics.Record("query", time.Now())
return i.Adapter.Query(ctx, q) // method promoted from *Adapter
}
*InstrumentedAdapter satisfies retrieval.Retriever because *InstrumentedAdapter's method set includes the promoted methods of *openai.Adapter. But InstrumentedAdapter.Adapter is a copy. Any mutation inside Adapter—updating the cache, rotating credentials, draining a connection—operates on the copy. The original is untouched. In a long-running service with connection reuse or token refresh, this is a latent correctness bug that manifests under load, not in unit tests.
The Serialization Trap
The copy problem compounds when types cross serialization boundaries. In event-driven microservices, it's common to pass handler structs through configuration loading or dependency injection frameworks that use reflection.
Reflection in Go uses reflect.Value. When you call reflect.ValueOf(adapter) on a value type, you get an unaddressable value. Methods with pointer receivers are not in the method set of the value, so they're not callable through reflection on that value. The DI framework silently falls back, skips the method, or panics.
The invariant that matters operationally: if any method on a type has a pointer receiver, the type should only ever be passed and stored as a pointer. This should be enforced at the package boundary with a constructor:
// Force pointer-only usage
func NewAdapter(cfg Config) *Adapter {
return &Adapter{
cfg: cfg,
client: &http.Client{Timeout: cfg.Timeout},
cache: make(map[string][]float32),
}
}
// Prevent value copies with a noCopy guard for go vet
type Adapter struct {
noCopy noCopy
cfg Config
client *http.Client
mu sync.Mutex
cache map[string][]float32
}
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
go vet's copylocks analysis will flag any copy of Adapter because sync.Mutex is itself guarded. Adding noCopy makes the intent explicit and catches cases where the mutex is extracted before embedding.
Interface Width as a Package Boundary Signal
Interface width—number of methods—has a direct relationship to testability and coupling across package boundaries. A Store interface with Get, Put, Delete, List, Watch, Compact, and Health methods forces every test double to implement all eight methods, most of which are irrelevant to the unit under test.
The production consequence is that teams create BaseStore structs with no-op implementations, which embed well but obscure which methods actually matter for a given code path. When a new method is added to the interface (say, Compact), every BaseStore silently satisfies the new interface with a no-op, hiding the fact that the caller now has a latent correctness issue.
Narrow interfaces, defined by the consumer not the producer, solve this:
// Consumer-side interface in the background job package
package compactor
type Compactable interface {
Compact(ctx context.Context, before time.Time) (int64, error)
Health(ctx context.Context) error
}
The Redis store and the MongoDB store each implement Compactable if they implement those two methods—regardless of what else they implement. No embedding of no-ops. No fake base structs. Test doubles need two methods. The compiler enforces that the real type satisfies the consumer's contract.
Generics and Interface Satisfaction
Go generics add a third axis: type constraints. A constraint is an interface, and method sets apply to type parameters. But there's a subtlety: you cannot use a method with a pointer receiver on a type parameter constrained to a non-pointer type:
type Initializable interface {
Init() error
}
func Setup[T Initializable](v T) error {
return v.Init()
}
type Worker struct{ name string }
func (w *Worker) Init() error { return nil }
// Setup(Worker{}) fails: Worker does not implement Initializable
// Setup(&Worker{}) works: *Worker implements Initializable
In generic infrastructure code—connection pool factories, middleware chains, SDK client builders—this forces a design choice: constrain on *T explicitly, or require the caller to pass pointers. The former requires a two-constraint pattern:
func Setup[T any, PT interface {
*T
Initializable
}](factory func() T) error {
v := factory()
pt := PT(&v)
return pt.Init()
}
This is correct but adds indirection that callers must understand. For most backend service code, the simpler answer is: define your generic constraints over pointer types from the start, and document why.
Decision Framework
When designing types at a package boundary in a Go backend service:
1. If any method needs to mutate state or holds a lock, use a pointer receiver everywhere on that type. Mixed receivers on a single type create a fragmented method set that satisfies some interfaces but not others depending on whether you have a pointer.
2. Embed by pointer when the embedded type has pointer-receiver methods and shared mutable state must propagate. Embed by value only for pure value types with no pointer receivers and no mutation.
3. Define interfaces in the consumer package, not the producer package. Width should reflect what the consumer actually calls. This makes test doubles cheap and prevents interface creep.
4. Use constructors that return pointers for any type with a mutex, channel, or pointer-receiver method. Add a noCopy guard. Make value copying a go vet failure, not a code review catch.
5. For generic infrastructure, decide up front whether type parameters are pointer-constrained. Mixed generics that work on both pointer and value types require the two-constraint pattern and cost readability. If your concrete types are all structs with pointer receivers—and in backend services they usually are—constrain on pointer types and document it.
6. Audit interface satisfaction across package boundaries as part of CI. A compile-time assignment check in a _test.go or doc.go file makes satisfaction explicit and breaks the build if a refactor removes a method:
var _ retrieval.Retriever = (*openai.Adapter)(nil)
var _ retrieval.Retriever = (*InstrumentedAdapter)(nil)
This is one line per type. It costs nothing at runtime. It makes the contract visible and breaks the build the moment a method disappears.
Method sets are not a beginner topic. They're the mechanical foundation of every interface-based seam in a Go backend, and getting them wrong quietly—through value embedding, receiver inconsistency, or interface width creep—produces bugs that survive testing and surface under the conditions that are hardest to reproduce: long-running mutations, high concurrency, and cross-package reflection.
Top comments (0)