Go API design
The exported surface is a promise. Everything unexported can change freely; everything exported cannot.
Name packages for what they provide
A package name is a prefix on every identifier a caller reads, so it should say something.
Good: store, retry, httpclient, tokens
Bad: util, common, helpers, base, misc, models
util attracts unrelated code forever and tells a reader nothing at the call site. When you cannot name a package precisely, the contents do not belong together yet — put them where they are used and split later once a real seam appears.
Short, lowercase, one word, no underscores, no plurals. Organise by capability, not by layer: a handlers package holding forty unrelated HTTP handlers is a folder, not a package.
Export as little as possible
Start every type, function, field, and constant lowercase. Export when a caller outside the package needs it, and not before — unexporting later is a breaking change, exporting later is free.
internal/ enforces this at the compiler level: anything under internal/ is importable only by code rooted at its parent, so you can share code across your own packages without it becoming public API.
Constructors: options over parameters
A constructor that keeps growing parameters breaks every caller each time.
// Breaks on every addition
func New(addr string, timeout time.Duration, retries int, tls *tls.Config) *Client
Functional options add configuration without breaking anyone, and keep the common call short:
type Option func(*Client)
func WithTimeout(d time.Duration) Option { return func(c *Client) { c.timeout = d } }
func WithRetries(n int) Option { return func(c *Client) { c.retries = n } }
func New(addr string, opts ...Option) *Client {
c := &Client{addr: addr, timeout: 30 * time.Second, retries: 3}
for _, opt := range opts {
opt(c)
}
return c
}
client := New("api.example.com") // sane defaults
client := New("api.example.com", WithTimeout(5*time.Second))
Required arguments stay positional; optional ones become options. For two or three settings that will not grow, an exported config struct is simpler and honest — do not reach for options reflexively.
Context first, error last
func (s *Store) Get(ctx context.Context, id string) (*Order, error)
Anything doing I/O, blocking, or spawning work takes ctx as its first parameter, even if today's implementation ignores it — adding it later breaks every caller. Never put a context.Context in a struct field.
Take the narrowest input
Accept io.Reader over *os.File, an interface over a struct, a slice over a channel where either works. Return concrete types so callers keep access to everything the type offers.
No package-level side effects
init() that dials a database, reads a file, or registers global state makes the package impossible to test and its import order significant. Do the work in an exported constructor the caller controls. Package-level mutable variables are shared state with no owner — the exception is a documented sentinel error or a genuinely immutable table.
Avoid a package-level default instance that callers mutate. Let the caller construct what it needs and pass it down.
Design errors as part of the API
Callers branch on failures, so failures are API. Decide deliberately which conditions are inspectable, export those sentinels or types, and document them on the function that returns them. Everything else stays an opaque wrapped error you are free to reword.
Compatibility
Within a major version these break callers, and are therefore off limits: renaming or removing anything exported, adding a parameter, changing a return type, adding a method to an interface others implement, changing a struct another package constructs with positional literals.
These are safe: adding a new function or type, adding a field to a struct callers build with field names, adding a method to a concrete type.
Breaking on purpose means /v2 in the module path — semantic import versioning lets both versions coexist in one build, which is what makes migration possible at all.
Document the exported surface
A doc comment on every exported identifier, starting with its name. A doc.go for the package overview when it needs more than a sentence. Runnable Example functions in the test file — they appear in the docs and fail the build when they drift from reality. If the doc comment is hard to write, the API is hard to use; fix the API.