# Method Sets, Embedding, and Interface Satisfaction in Go: The Hidden Contract Behind API Boundaries

> Source: <https://dev.to/neeraj_singhi_golang/method-sets-embedding-and-interface-satisfaction-in-go-the-hidden-contract-behind-api-boundaries-3m83>
> Published: 2026-09-24 10:45:01+00:00

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.

Go specifies method sets precisely. For a type `T`:

`T` contains all methods with receiver `*T` contains all methods with receiver For embedded types:

`S` contains an embedded field `*S` includes the promoted methods of both 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.

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 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—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.

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.

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:

``` js
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.
