Interface Pollution and Package Boundaries in Go Microservices: Designing for Seam Clarity
How interface placement, method set design, and package topology determine testability, coupling, and operational cost in production Go backends.
The Real Cost of Interface Placement
In large Go backends—services with ten or more packages, multiple storage adapters, and layered middleware—the position of an interface definition determines more than testability. It determines compilation blast radius, mock proliferation, dependency inversion fidelity, and the cognitive overhead of future contributors navigating the package graph.
Go's structural typing makes it easy to define interfaces anywhere. That freedom is a design trap. Every interface defined on the producer side—inside the package that implements it—becomes a contract the producer controls, forcing consumers to import that package and accept its full dependency closure. Every interface defined on the consumer side gives the consumer the minimal method set it actually needs, keeps the import graph shallow, and makes fake implementations trivial to write without a mocking framework.
This is not theory. In a service that connects to MongoDB, Redis, and an upstream gRPC API, the package boundary decisions made at project start survive for years and propagate into every new feature, test, and on-call runbook.
Method Set Size as a Coupling Signal
Consider an order-processing microservice. A naive design produces:
// package store — producer-side interface
type OrderRepository interface {
Create(ctx context.Context, o Order) error
GetByID(ctx context.Context, id string) (Order, error)
Update(ctx context.Context, o Order) error
Delete(ctx context.Context, id string) error
ListByCustomer(ctx context.Context, customerID string, page, limit int) ([]Order, error)
CountByStatus(ctx context.Context, status string) (int64, error)
BulkUpsert(ctx context.Context, orders []Order) error
}
Every consumer of store.OrderRepository must now satisfy seven methods. A fulfillment handler that only reads orders by ID drags in the full surface. Its test double must implement six irrelevant methods. When BulkUpsert is added later, every existing fake breaks at compile time across multiple packages.
The production alternative is consumer-side, narrow interfaces:
// package fulfillment — consumer defines what it needs
type orderReader interface {
GetByID(ctx context.Context, id string) (store.Order, error)
}
type Handler struct {
orders orderReader
}
The store.MongoOrderStore satisfies this interface implicitly. The fulfillment package never imports an interface file from store; it imports only the concrete type through its public constructor, which returns a concrete type, not an interface. The interface lives where the dependency flows to, not where the implementation lives.
This is the dependency rule expressed in Go's type system: interfaces belong to the package that consumes them.
Package Topology and Compilation Blast Radius
In a monorepo with shared packages, interface pollution has a measurable build-time cost. A types or interfaces package that collects all service contracts creates a dependency magnet. Every package imports it. When a method signature changes, the entire graph recompiles.
The alternative topology:
cmd/
orderservice/
internal/
fulfillment/ # defines orderReader, shipmentWriter locally
billing/ # defines orderReader locally (different minimal shape)
store/ # MongoDB implementation; no interfaces exported
cache/ # Redis implementation; no interfaces exported
grpcadapter/ # upstream gRPC client wrapper
Here store and cache export concrete types and constructors. fulfillment and billing each define the narrow interfaces their business logic needs. The concrete types satisfy both independently. When store.MongoOrderStore adds a new method, only store recompiles. Nothing in fulfillment or billing changes unless the methods those packages depend on change.
This topology also makes feature flag injection, shadow-write patterns for MongoDB collection migrations, and Redis cache warm-up strategies straightforward: you swap the concrete type at the composition root (cmd/orderservice/main.go) without touching business logic packages.
Generics at Package Boundaries: When They Help and When They Leak
Go 1.18 generics introduce a new class of package boundary question: where does a generic constraint belong?
A common pattern in data-intensive services is a paginated query helper:
// package query
type Page[T any] struct {
Items []T
NextCursor string
Total int64
}
type Fetcher[T any] interface {
Fetch(ctx context.Context, cursor string, limit int) (Page[T], error)
}
This looks clean. The problem surfaces when Fetcher[T] is placed in a shared package that both fulfillment and billing import. Now the generic interface is a shared contract. If you need to add a filter parameter to Fetch in one domain, you either change the shared interface—breaking all consumers—or duplicate it. The generic abstraction created false reuse.
The production rule: generic types that represent data shapes (Page[T], Result[T], Event[T]) can live in a shared package because they carry no behavior. Generic interfaces belong close to their consumer, same as non-generic ones.
Error Semantics Across Package Boundaries
Error design is the most operationally consequential package boundary decision. A service that returns raw error values across package boundaries—including sentinel errors and type-asserted errors from deep dependencies—leaks implementation details into callers.
The pattern that survives production:
// package store
type NotFoundError struct {
Resource string
ID string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %s not found", e.Resource, e.ID)
}
// store wraps driver errors at the boundary
func (r *MongoOrderStore) GetByID(ctx context.Context, id string) (Order, error) {
var o Order
err := r.col.FindOne(ctx, bson.M{"_id": id}).Decode(&o)
if errors.Is(err, mongo.ErrNoDocuments) {
return Order{}, &NotFoundError{Resource: "order", ID: id}
}
if err != nil {
return Order{}, fmt.Errorf("store.GetByID: %w", err)
}
return o, nil
}
Callers use errors.As against *store.NotFoundError. They never import mongo. When the storage layer switches from MongoDB to a different driver, no caller package changes. The error type is the stable contract.
The failure mode is exporting raw driver errors or, worse, logging them inside store and returning a generic error. Callers cannot distinguish transient network errors from missing documents, which collapses retry logic, dead-letter routing, and alerting into a single undifferentiated error rate.
Testing Seams Without Mock Frameworks
With consumer-side narrow interfaces, test doubles are small and explicit:
// fulfillment/handler_test.go
type stubOrderReader struct {
order store.Order
err error
}
func (s *stubOrderReader) GetByID(_ context.Context, _ string) (store.Order, error) {
return s.order, s.err
}
func TestHandler_FulfillOrder_NotFound(t *testing.T) {
h := Handler{orders: &stubOrderReader{err: &store.NotFoundError{Resource: "order", ID: "x"}}}
err := h.FulfillOrder(context.Background(), "x")
var nfe *store.NotFoundError
if !errors.As(err, &nfe) {
t.Fatalf("expected NotFoundError, got %v", err)
}
}
No reflection, no generated mocks, no framework import. The stub is eleven lines. It compiles instantly and fails loudly if orderReader changes. This is the payoff of narrow consumer-side interfaces: test seams cost almost nothing to write and maintain.
Decision Framework
Apply these rules at each package boundary decision:
1. Interface placement: Define interfaces in the package that consumes them, not the package that implements them. The only exception is a capability interface that multiple unrelated consumers need with an identical method set—and that situation is rare.
2. Method set budget: Each interface method is a future maintenance obligation. If a consumer needs only two of seven available methods, its local interface has two methods. Wider interfaces signal that the consuming package has too many responsibilities.
3. Error type ownership: Domain error types (NotFoundError, ConflictError, ValidationError) live in the package that performs the operation. They wrap driver/library errors. Callers import the domain error type, never the driver.
4. Generic interfaces vs. generic data types: Generic data shapes belong in shared packages. Generic interfaces belong with their consumers.
5. Compilation graph check: If changing one file triggers recompilation of more than one business logic package, an interface or type is in the wrong place. The go build -v output is diagnostic.
6. Test double cost: If writing a test double requires more than 20 lines or a mocking framework, the interface is too wide or in the wrong package.
Package boundary discipline is not about following patterns. It is about controlling the rate at which a change in one part of the system propagates—in compilation time, test overhead, and operational surprise—into every other part.