← All blogs
go · microservices · api-design · backend

Interface Pollution in Go Microservices: When Abstraction Becomes a Liability

How premature or over-broad Go interfaces degrade testability, obscure error semantics, and create hidden coupling across service boundaries.

Interface Pollution in Go Microservices: When Abstraction Becomes a Liability

Go's implicit interface satisfaction is a genuine design win until it isn't. In large backend systems—services coordinating MongoDB reads, Redis cache layers, AWS SDK calls, and inter-service RPCs—the temptation is to reach for interfaces early and broadly. The result is what I call interface pollution: a codebase where abstractions exist not to decouple behavior but to satisfy a vague instinct about testability or future flexibility. The concrete cost is real: degraded error semantics, invisible coupling, mock explosions in test suites, and API surfaces that resist safe evolution.

This article examines the mechanics of that failure mode and the design decisions that prevent it.

The Root Problem: Interfaces Defined at the Wrong Boundary

Go's specification is explicit: interfaces are satisfied implicitly, and the conventional wisdom—attributed to the standard library's own design—is that interfaces should be defined by the consumer, not the producer. A package that owns a concrete MongoRepository should not export a MongoRepositoryInterface wrapping every method it has. The consumer that needs subset behavior defines the narrow interface it actually depends on.

In practice, this breaks down under two pressures:

  1. Framework imitation. Engineers coming from Java or Python ecosystems import the pattern of declaring interfaces alongside their implementations "for DI."
  2. Preemptive mocking. Teams define wide interfaces so every method is mockable from day one, before any test actually exercises more than two of them.

The consequence is an interface like this:

// Declared in the repository package — wrong location, wrong scope
type UserRepository interface {
    FindByID(ctx context.Context, id string) (*User, error)
    FindByEmail(ctx context.Context, email string) (*User, error)
    Create(ctx context.Context, u *User) error
    Update(ctx context.Context, u *User) error
    Delete(ctx context.Context, id string) error
    ListByTenant(ctx context.Context, tenantID string, opts ListOpts) ([]*User, error)
    CountByTenant(ctx context.Context, tenantID string) (int64, error)
    BulkUpsert(ctx context.Context, users []*User) error
}

Any service importing this interface now carries a dependency on BulkUpsert even if it only ever calls FindByID. Worse, any mock of this interface must implement all eight methods or the compilation fails. When BulkUpsert gains a new parameter six months later, every consumer's mock breaks, even those that never call it.

Error Semantics and the Interface Boundary

Wide interfaces actively harm error handling. When a concrete MongoRepository returns a mongo.CommandError with a code indicating a duplicate key, the calling service can inspect that code and decide whether to retry or surface a 409. Once that repository hides behind a broad interface, the calling layer has two bad choices:

  • Type-assert on the concrete error and re-import the driver package, collapsing the abstraction entirely.
  • Wrap the error into a domain type at the repository layer, which is correct but requires every method on that wide interface to enforce the same wrapping discipline consistently.

The narrower the interface, the easier it is to enforce a coherent error contract at the boundary. A UserLookup interface with a single FindByID method can document and enforce exactly one error taxonomy. An eight-method blob cannot.

The idiomatic pattern:

// Defined in the service package that consumes it
type UserLookup interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

// Domain error type owned by the repository package
type NotFoundError struct {
    ID string
}
func (e *NotFoundError) Error() string {
    return fmt.Sprintf("user %s not found", e.ID)
}

// Service code can now make a clean decision
user, err := s.lookup.FindByID(ctx, id)
if err != nil {
    var nfe *NotFoundError
    if errors.As(err, &nfe) {
        return nil, status.Errorf(codes.NotFound, "user not found")
    }
    return nil, status.Errorf(codes.Internal, "lookup failed")
}

This pattern is impossible to maintain at scale when the interface has eight methods and each method has a different error taxonomy that callers inconsistently inspect.

Method Sets and the Hidden Coupling Problem

Go's method set rules compound the problem. A value of type T satisfies an interface only if all required methods are defined on T (not *T). A pointer *T satisfies interfaces requiring methods on either T or *T. This is elementary Go, but wide interfaces create a trap: if a concrete type evolves to need pointer receivers for some new method (say, because it acquires mutable connection-pool state), the entire interface satisfaction may silently shift.

More insidiously, when a broad repository interface is passed through multiple service layers and eventually stored in a struct field, the actual type stored is interface{} at runtime. The garbage collector cannot inline the dispatch; every method call goes through the interface table. For hot paths—cache lookups on Redis, per-request auth token validation—this is a measurable overhead, not a theoretical one.

The Testing Seam Fallacy

The standard justification for wide interfaces is testability: "We need to mock the entire repository to test the service." This reasoning inverts the causality. If a service genuinely calls eight distinct repository methods in a single handler, that handler has too many responsibilities and the test complexity is correctly signaling a design problem.

The discipline of narrow interfaces forces the right decomposition:

// Before: service depends on everything
type OrderService struct {
    repo OrderRepository // 12-method interface
}

// After: dependencies are explicit and minimal
type OrderService struct {
    lookup   OrderLookup    // FindByID
    placer   OrderPlacer    // Create
    auditor  OrderAuditor   // RecordEvent
}

Now each interface is independently testable with a two-line struct implementation rather than a generated mock carrying twelve stub methods. The test file stops being a maintenance artifact and starts being a readable specification of the dependency's contract.

Package Boundary Design: Where Interfaces Live

A practical rule for large Go backends: interfaces belong to the package that is hurt by the dependency, not the package that provides the behavior. This is the consumer-defines pattern, and it has structural implications:

  • Repository packages export concrete types and domain errors.
  • Service packages define narrow interfaces matching exactly the methods they invoke.
  • Shared contract packages (if needed across multiple services) export only data types, never behavior interfaces.

When an interface must cross a package boundary—for example, a common audit interface used by three different services—its surface should be audited for the minimum common denominator, not the superset. If two services need RecordEvent and one additionally needs QueryEvents, the shared interface contains only RecordEvent. The third service defines its own extended interface locally.

Generics Do Not Solve This; They Amplify It

Since Go 1.18, there is a new vector for interface pollution: over-generic repository patterns.

// Seductive but dangerous
type Repository[T any] interface {
    FindByID(ctx context.Context, id string) (T, error)
    Create(ctx context.Context, entity T) error
    Update(ctx context.Context, entity T) error
    Delete(ctx context.Context, id string) error
    List(ctx context.Context, opts ListOpts) ([]T, error)
}

This looks like a principled abstraction. It is a wide interface with a type parameter. Every problem described above applies, now with the additional complexity that type constraints interact with interface satisfaction in non-obvious ways when T is itself an interface or a pointer type. The error semantics problem is unchanged: a generic Repository[Order] still cannot encode the specific error types that an order store produces differently from a user store.

Generics are appropriate for data-structure code (trees, queues, pagination cursors) not for service-boundary behavior contracts.

Decision Framework

Apply this sequence when designing an interface in a Go backend service:

  1. Does more than one concrete type implement this behavior today? If not, skip the interface. Add it when the second implementation appears.
  2. Is the interface defined by the consumer or the producer? If the producer owns it, move it to the consumer package.
  3. How many methods does the caller actually invoke in the code under test? Count them. Define an interface with exactly those methods.
  4. Does every method have a documented, exhaustive error contract? If not, the interface is not ready to be published.
  5. Will a generated mock of this interface compile and run without implementing stub methods you don't exercise? If mock setup requires more lines than the test itself, the interface is too wide.
  6. For generic repository patterns: confirm that the generic saves duplication in actual data-structure code, not in behavior definition. Behavior interfaces should remain concrete and narrow.

Interface design in Go is not a matter of style preference. In a distributed backend where services evolve independently, interface width is a coupling surface. Keep it minimal, keep it consumer-owned, and treat every method you add as a contract obligation you must maintain across every caller, mock, and API version that depends on it.

Interface Pollution in Go Microservices: When Abstraction Becomes a Liability | Neeraj Singhi