Go Structs and Interfaces
Design the smallest type boundary that expresses the current requirement. Prefer concrete code until an interface, embedding relationship, or abstraction has a demonstrated consumer.
Inspect the Existing Boundary
Before changing a type:
- Find its constructors, method set, interface assignments, embeddings, direct struct literals, serialization tags, generated code, and external consumers.
- Check whether the zero value is intentionally useful or construction is deliberately required.
- Determine ownership: who may mutate fields, slices, maps, pointers, and embedded state?
- Identify copy-sensitive fields such as mutexes, atomics, pools, once values, file handles, or builders.
- Read repository conventions before introducing a new abstraction.
Choosing an Interface
Introduce an interface when it gives a present benefit, such as:
- a consumer needs only a small subset of a dependency;
- multiple implementations are already real;
- a test or boundary needs substitution that cannot be achieved more simply;
- a package dependency can be inverted cleanly.
Define consumer-specific interfaces near the consumer when practical. Keep them limited to the methods that consumer uses. Do not create an interface solely to mirror every method of one concrete type or to speculate about future implementations.
Returning a concrete type from a constructor often preserves useful API information. Returning an interface can still be appropriate when implementations are intentionally hidden, callers must not depend on concrete behavior, or compatibility requires the abstraction. Treat “accept interfaces, return structs” as a heuristic, not a law.
Use a compile-time assertion when documenting an important implementation relationship is valuable:
var _ io.Reader = (*Buffer)(nil)
Method Sets and Receivers
Choose pointer receivers when methods mutate the receiver, copying is unsafe or expensive, identity matters, or nil has documented semantics. Value receivers fit small value-like types whose methods do not mutate shared state.
Keep the receiver choice coherent enough that users can predict the method set, but do not change a stable API merely for visual uniformity. Remember:
- methods on
Tare in the method sets of bothTand*T; - methods on
*Tare only in the method set of*T; - map elements and some temporary values are not addressable;
- copying a struct containing synchronization state after first use is unsafe and is often detected by
go vet.
An interface is nil only when both its dynamic type and value are nil. A typed nil pointer stored in an interface is non-nil:
var p *bytes.Buffer
var r io.Reader = p
fmt.Println(r == nil) // false
Check or normalize the concrete pointer before assigning it to an interface when nil has domain meaning; do not use reflection merely to make every interface nil-aware.
Embedding
Embedding promotes methods and can make the outer type satisfy interfaces. Use it when promotion is part of the public design. Use a named field when the dependency is an implementation detail or only a few operations should be exposed.
Before embedding, review:
- the full promoted API, including future additions to the embedded type;
- zero-value and nil behavior;
- ambiguous selectors from multiple embeddings;
- whether callers could bypass invariants through promoted methods;
- serialization behavior of embedded fields.
Embedding is composition, not inheritance. Promoted methods still operate on the embedded receiver.
Struct Design
- Make ownership and mutation visible. Clone slices or maps at boundaries only when the API promises isolation; unnecessary copying can be expensive.
- Prefer a useful zero value when it is natural and concurrency-safe. Otherwise, require construction and validate invariants there.
- Avoid exported fields when arbitrary mutation would violate invariants.
- Do not add
anyor generics mechanically. Use a concrete type, a focused interface, or a type parameter according to the actual relationship among values. - Consider field layout only after measurement shows size or cache behavior matters; readability and compatibility normally dominate.
Use pointer fields when a wire contract must distinguish omitted or null from an explicit zero. For example:
Name *string `json:"name,omitempty"`
This omits nil while preserving a pointer to ""; verify the exact serializer because tags are not interpreted by Go itself.
Type Assertions and Switches
Use the comma-ok form when a failed assertion is expected:
f, ok := w.(interface{ Flush() error })
if ok {
return f.Flush()
}
A direct assertion is reasonable only when the invariant is established and a panic is an intentional failure mode. Prefer a type switch when handling several dynamic types. Avoid using assertions to recover information an overly broad interface discarded.
Struct Tags and External Contracts
Tags belong to the serializer or framework that interprets them. Verify exact syntax in that tool's documentation. Do not assume every exported field needs every tag.
When changing a field or tag, check JSON/YAML/XML/database compatibility, omitted versus zero values, unknown-field handling, embedded-field conflicts, generated schemas, migrations, and reflection-based validators. A Go-only rename should normally leave the external name unchanged.
Validation
Format changed files and run the repository's tests and go vet. Add focused checks for interface satisfaction, zero-value behavior, serialization round trips, aliasing, and concurrent access where those contracts changed. For exported types, call out source and behavioral compatibility explicitly.