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.
Search across all documentation pages
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.
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.
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:
Config struct with 20 fields.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:
PaymentGateway) keeps fakes one method wide.New documents required dependencies without a DI framework.now field shows how to test time without exporting globals.implements declarations.%w links causes while preserving sentinel checks via errors.Is and type checks via errors.As.context.Context is the first parameter for cancellation and request-scoped values on I/O boundaries.| 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 |
| 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.
// 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
}Start - mutating running servers is a common footgun.Config structs - callers do not know required fields; zero values silently misconfigure. Fix: validate in New, return error, provide defaults only for safe knobs.*sql.DB through every layer - couples domain to storage. Fix: repository interface with domain types at the service edge.internal/platform/core with no callers. Fix: wait for the second consumer before extracting.context.Context per method, never save it in fields./v2) for incompatible API shifts. Fix: plan v2 when removing or renaming exports.| 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 |
Producers should not depend on every consumer's abstraction.
Small consumer interfaces document exactly what the caller needs and keep mocks minimal.
No fixed cap - aim for one coherent responsibility.
If godoc reads like a catalog of unrelated utilities, split packages.
When most callers accept defaults and a minority tune timeouts, URLs, or credentials.
Required dependencies belong in New parameters, not options.
Use module major version suffixes (/v2) and keep v1 importing paths stable per the Go 1 compatibility promise for your own v1 line.
Return wrapped errors with documented sentinels for classification.
Export error types only when callers need structured fields beyond errors.As.
Thin: parse input, call a service method, translate errors to status codes.
If handlers exceed ~40 lines, move branching into testable services.
No.
Use generics when they remove real duplication; avoid them when a single function reads clearer.
Share protobuf/OpenAPI contracts, not giant shared Go config structs.
Keep service-specific tuning local; share only stable cross-service nouns.
Packages express boundaries; files organize readability inside a boundary.
Split packages when import cycles or unrelated concepts appear, not when files feel long alone.
Use godoc comments on exported symbols, CHANGELOG entries, and module tags.
Call experimental APIs Experimental in name or document instability in package comment.
Export fields when invariants are simple.
Use methods when validation, locking, or derived values are required - not JavaBean symmetry.
Simple APIs can hide pooling or batching inside the package.
Profile before adding complexity to exported surfaces - keep optimizations unexported.
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).
Reviewed by Chris St. John·Last updated Jul 19, 2026