Simplicity as a Deliberate Constraint in API Design
Go's language restraint pushes library and service APIs toward small surfaces, explicit dependencies, and predictable behavior.
That is not a style preference - it is how maintainable Go systems survive years of staff turnover and dependency upgrades.
Summary
Go favors boring APIs: few constructors, narrow interfaces, explicit errors, and packages that do one job.
That restraint speeds reviews, shrinks test matrices, and keeps upgrades compatible with the Go 1 promise.
This page shows how to design package and HTTP boundaries that match the language instead of fighting it.
When to reach for this: You are publishing a shared internal library, stabilizing a public module, or refactoring a "god package" before a major version bump.
Recipe
Quick-reference recipe card - copy-paste ready.
// Consumer-side interface - one method, defined where used.
type ObjectStore interface {
Put(ctx context.Context, key string, body io.Reader) error
}
// Constructor returns concrete type; keep options functional.
type S3Store struct { /* ... */ }
func NewS3Store(cfg Config) (*S3Store, error) { /* validate cfg */ return &S3Store{}, nil }
// Function accepts interface, documents errors.
func UploadReport(ctx context.Context, store ObjectStore, r io.Reader) error {
if err := store.Put(ctx, "report.pdf", r); err != nil {
return fmt.Errorf("upload report: %w", err)
}
return nil
}When to reach for this:
- A package exports more than three constructors or a
Configstruct with 20 fields. - Callers mock interfaces that require stubbing unused methods.
- HTTP handlers reach into global singletons instead of accepting dependencies.
- Error strings are the only contract between services.
Working Example
package checkout
import (
"context"
"errors"
"fmt"
"time"
)
// Domain errors are package-level sentinels - stable for errors.Is.
var ErrInsufficientFunds = errors.New("checkout: insufficient funds")
type PaymentGateway interface {
Charge(ctx context.Context, customerID string, cents int64) (chargeID string, err error)
}
type Service struct {
gw PaymentGateway
now func() time.Time
}
func New(gw PaymentGateway) *Service {
return &Service{gw: gw, now: time.Now}
}
type Request struct {
CustomerID string
Cents int64
}
func (s *Service) Pay(ctx context.Context, req Request) (string, error) {
if req.Cents <= 0 {
return "", fmt.Errorf("checkout: invalid amount %d", req.Cents)
}
id, err := s.gw.Charge(ctx, req.CustomerID, req.Cents)
if err != nil {
return "", fmt.Errorf("checkout: charge: %w", err)
}
return id, nil
}What this demonstrates:
- Small interface on the consumer (
PaymentGateway) keeps fakes one method wide. - Concrete service struct with
Newdocuments required dependencies without a DI framework. - Sentinel error plus wrapping gives operators stable classification and debuggable chains.
- Request struct groups inputs instead of growing function arity.
- Clock injection via
nowfield shows how to test time without exporting globals.
Deep Dive
How It Works
- Go packages are the unit of API design; exported identifiers start with an uppercase letter and become compatibility commitments.
- Interfaces are satisfied implicitly - design them at call sites so producers are not forced into boilerplate
implementsdeclarations. - Errors are values; wrapping with
%wlinks causes while preserving sentinel checks viaerrors.Isand type checks viaerrors.As. context.Contextis the first parameter for cancellation and request-scoped values on I/O boundaries.
API Surface Rules at a Glance
| Rule | Rationale | Violation smell |
|---|---|---|
| Accept interfaces, return structs | Testability without wide mocks | Exported interface with 10 methods |
| One job per package | Clear import graphs | util package with unrelated helpers |
| Functional options for rare knobs | Avoid 12-field Config | New(a,b,c,d,e,f...) |
| Stable errors as sentinels | SRE alert routing | String matching in logs |
Pass context on I/O | Uniform cancellation | context.Background() hidden inside library |
HTTP and gRPC Boundaries
| Layer | Keep simple | Defer complexity |
|---|---|---|
| Handler | Decode, authorize, call service, map errors to status | Business rules |
| Service | Orchestrate domain steps, transactions | SQL details |
| Repository | CRUD with context | Policy |
For gRPC, prefer protobuf messages that mirror domain nouns, not every internal DTO.
Map domain errors to codes.InvalidArgument, codes.NotFound, and codes.Internal in one place.
Go Notes
// Functional option - scales without Config explosion.
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func NewServer(opts ...Option) *Server {
s := &Server{timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}- Options are idiomatic when defaults are sane and overrides are uncommon.
- Unexported fields keep invariants inside the package.
- Document which options are safe to call after
Start- mutating running servers is a common footgun.
Gotchas
- Exported interfaces grow without bound - every new method breaks all mocks. Fix: split interfaces or keep them unexported inside the package that needs fakes.
- God
Configstructs - callers do not know required fields; zero values silently misconfigure. Fix: validate inNew, return error, provide defaults only for safe knobs. - Leaking
*sql.DBthrough every layer - couples domain to storage. Fix: repository interface with domain types at the service edge. - Stringly-typed errors across services - alerts become regex archaeology. Fix: document sentinel errors and map to HTTP/gRPC codes centrally.
- Premature abstraction packages -
internal/platform/corewith no callers. Fix: wait for the second consumer before extracting. - Context in structs - stores cancelation scoped incorrectly. Fix: pass
context.Contextper method, never save it in fields. - Breaking changes without major version - modules use import paths (
/v2) for incompatible API shifts. Fix: plan v2 when removing or renaming exports.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Functional options | Several optional tuning params | Only one or two required deps - use New(dep) |
| Builder pattern (chained) | Fluent DSL for complex test fixtures | Production constructors - options are more idiomatic |
| Heavy DI frameworks (wire, dig) | Very large graphs with codegen | Small services - manual main wiring is clearer |
Generic APIs (func Do[T any]) | Real duplication across types | Single-type functions - generics add noise |
Global singletons (var DB) | Quick prototypes | Libraries and services reviewed for production |
FAQs
Why define interfaces on the consumer side?
Producers should not depend on every consumer's abstraction.
Small consumer interfaces document exactly what the caller needs and keep mocks minimal.
How many exported functions should a package have?
No fixed cap - aim for one coherent responsibility.
If godoc reads like a catalog of unrelated utilities, split packages.
When are functional options worth the indirection?
When most callers accept defaults and a minority tune timeouts, URLs, or credentials.
Required dependencies belong in New parameters, not options.
How do I version breaking API changes?
Use module major version suffixes (/v2) and keep v1 importing paths stable per the Go 1 compatibility promise for your own v1 line.
Should libraries return concrete errors or types?
Return wrapped errors with documented sentinels for classification.
Export error types only when callers need structured fields beyond errors.As.
How simple should HTTP handlers stay?
Thin: parse input, call a service method, translate errors to status codes.
If handlers exceed ~40 lines, move branching into testable services.
Does simplicity ban generics in public APIs?
No.
Use generics when they remove real duplication; avoid them when a single function reads clearer.
How do I prevent config drift across microservices?
Share protobuf/OpenAPI contracts, not giant shared Go config structs.
Keep service-specific tuning local; share only stable cross-service nouns.
What is the balance between few packages and many tiny files?
Packages express boundaries; files organize readability inside a boundary.
Split packages when import cycles or unrelated concepts appear, not when files feel long alone.
How do I document API stability?
Use godoc comments on exported symbols, CHANGELOG entries, and module tags.
Call experimental APIs Experimental in name or document instability in package comment.
Are getters/setters idiomatic in Go?
Export fields when invariants are simple.
Use methods when validation, locking, or derived values are required - not JavaBean symmetry.
How does simplicity interact with performance?
Simple APIs can hide pooling or batching inside the package.
Profile before adding complexity to exported surfaces - keep optimizations unexported.
Related
- Why Go Exists - language-level simplicity goals
- Go Philosophy Basics - accept interfaces, return structs in practice
- Go Philosophy Best Practices - team-wide idioms checklist
- When Go Is the Wrong Tool - when not to invest in Go API polish
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).