Bytes crossing the process boundary are untyped and untrusted. This chapter governs how Go turns structs into JSON and back: struct tags for field mapping, omitempty only where absence is legal, custom marshalers for types with special wire forms, time.Time/time.Duration so units never get guessed, and strings for money so IEEE 754 never rounds a price. Every decoded value is validated at the boundary before the domain sees it. It also covers the construction idioms — field-named literals, sized maps and slices, composite literals over new() — that keep the in-memory values these formats carry honest.
// Hotel is the wire DTO. Every field is tagged explicitly; optional fields
// carry omitempty, required fields never do.
type Hotel struct {
ID string `json:"id"` // required -- no omitempty
Name string `json:"name"` // required -- no omitempty
Rating float64 `json:"rating"` // required -- no omitempty
SortBy string `json:"sort_by,omitempty"` // optional
CreatedAt time.Time `json:"created_at"` // RFC 3339 on the wire
}
func DecodeHotel(r io.Reader) (Hotel, error) {
var hotel Hotel
decoder := json.NewDecoder(r)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&hotel); err != nil {
return Hotel{}, fmt.Errorf("decode hotel: %w", err)
}
// The decoder does not know the business rules. Validate at the boundary.
if hotel.ID == "" {
return Hotel{}, fmt.Errorf("hotel ID is required")
}
if hotel.Rating < 0 || hotel.Rating > 5 {
return Hotel{}, fmt.Errorf("hotel rating must be 0-5, got %f", hotel.Rating)
}
return hotel, nil
}This DTO maps every field with a struct tag and keeps omitempty off required fields (9.1, 9.2); CreatedAt is a time.Time serialized as RFC 3339, not a raw integer (9.4); the decoder streams from an io.Reader with DisallowUnknownFields() (9.8) and wraps its failure with %w; the boundary validates shape and ranges before returning, trusting nothing from outside the process (9.6, 9.7). A Money field on this struct would be a string, never a float64 (9.5).
Reasoning, step by step:
- Use
encoding/jsonfrom the standard library — it is the canonical Go serializer and needs no third-party dependency. - Map every field to its wire name with a struct tag rather than relying on Go's default field-name casing. The tag is the contract between the struct and the JSON; making it explicit means a field rename in Go never silently changes the wire format.
type Hotel struct {
ID string `json:"id"`
Name string `json:"name"`
Rating float64 `json:"rating"`
CreatedAt time.Time `json:"created_at"`
}Enforcement: review; encoding/json with explicit json: tags on serialized structs.
Reasoning, step by step:
- Use
omitemptyfor optional fields so a zero value drops out of the output cleanly. - Never put
omitemptyon a required field — it hides bugs. If a required field marshals to its zero value, the absence from the output is the signal that something is wrong;omitemptysilences that signal.
type SearchParams struct {
City string `json:"city"` // required -- no omitempty
Adults int `json:"adults,omitempty"` // optional
SortBy string `json:"sort_by,omitempty"` // optional
}Enforcement: review; omitempty on optional fields only, never on required fields.
Reasoning, step by step:
- When a type's wire form differs from its in-memory layout, implement
json.Marshaler/json.Unmarshalerso the special handling lives with the type instead of leaking into every call site. - Marshal through an explicit anonymous struct with its own tags, so the serialized shape is visible and controlled at the type, not inferred from the in-memory fields.
type Money struct {
Amount string // decimal as string, never float
Currency string
}
func (m Money) MarshalJSON() ([]byte, error) {
return json.Marshal(struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}{Amount: m.Amount, Currency: m.Currency})
}Enforcement: review; json.Marshaler/json.Unmarshaler for types whose wire form differs from their layout.
Reasoning, step by step:
- Use
time.Timefor instants andtime.Durationfor elapsed time. Never use raw integers for time values — they force every reader to guess the unit.
// Good -- type carries the unit
func NewPoller(interval time.Duration) *Poller { ... }
poller := NewPoller(10 * time.Second)
// Bad -- is this seconds? milliseconds? nanoseconds?
func NewPoller(interval int) *Poller { ... }
poller := NewPoller(10)- Compare times with
time.Timemethods, never==. The==operator does not account for the monotonic clock.
// Good
if deadline.Before(time.Now()) { ... }
if t1.Equal(t2) { ... }
// Bad -- does not account for monotonic clock
if deadline == time.Now() { ... }- Distinguish calendar operations from absolute durations.
AddDatewalks the calendar and handles DST changes;Addadvances by a fixed duration.
// "Same time tomorrow" (calendar) -- handles DST changes
newDay := now.AddDate(0, 0, 1)
// "Exactly 24 hours from now" (absolute)
next := now.Add(24 * time.Hour)- When interfacing with systems that don't support
time.Timeortime.Duration, include the unit in the field name so the ambiguity dies at the boundary.
// Good -- unit is explicit
type ExternalConfig struct {
IntervalMillis int `json:"interval_millis"`
CreatedAtUnix int64 `json:"created_at_unix"`
}
// Bad -- ambiguous
type ExternalConfig struct {
Interval int `json:"interval"`
CreatedAt int64 `json:"created_at"`
}- Use RFC 3339 (ISO 8601) format for timestamp strings in APIs — it is the standard Go serialization format and is widely supported. For date-only fields, always keep the value as a
time.Timebehind a custom type that serializes the date portion.
type Date struct{ time.Time }
func (d Date) MarshalJSON() ([]byte, error) {
return json.Marshal(d.Format("2006-01-02"))
}
func (d *Date) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return fmt.Errorf("parse date %q: %w", s, err)
}
d.Time = t
return nil
}Enforcement: review; time.Time/time.Duration for time values, RFC 3339 on the wire, units in field names at non-time boundaries.
Reasoning, step by step:
- Never use
float64for money. IEEE 754 floating-point cannot represent most decimal fractions exactly, so arithmetic silently accumulates rounding error. - Use a string representation or
shopspring/decimal. Parse from strings and serialize to strings, so the decimal value crosses every boundary intact.
Enforcement: review; string or shopspring/decimal for monetary values, never float64.
Reasoning, step by step:
- Always validate after
json.Unmarshal. The decoder enforces the JSON shape but knows nothing about your business rules — a syntactically valid payload can still be semantically wrong. - Check the fields the domain depends on: required identifiers present, numeric values within range. Wrap the decode error with
%wso the chain survives.
if err := json.Unmarshal(data, &hotel); err != nil {
return fmt.Errorf("unmarshal hotel: %w", err)
}
if hotel.ID == "" {
return fmt.Errorf("hotel ID is required")
}
if hotel.Rating < 0 || hotel.Rating > 5 {
return fmt.Errorf("hotel rating must be 0-5, got %f", hotel.Rating)
}Enforcement: review; validation of required fields and ranges after every unmarshal.
Reasoning, step by step:
- Every byte from outside the process is suspect. Assume the sender is hostile.
- After deserialization, validate shape, types, ranges, and lengths before the value reaches domain logic.
Enforcement: review; shape, type, range, and length validation on all externally sourced data.
Reasoning, step by step:
- For large payloads, use
json.Decoderrather thanjson.Unmarshal. When reading from anio.Readerit decodes the stream directly instead of buffering the entire payload into memory. - Call
DisallowUnknownFields()so an unexpected field is an error, not silently dropped — the strictness catches client/server drift at the boundary.
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&req); err != nil {
return fmt.Errorf("decode request: %w", err)
}Enforcement: review; json.Decoder with DisallowUnknownFields() for reads from an io.Reader.
Reasoning, step by step:
- Prefer
%qover hand-quoting with\"...\"or single quotes.%qhandles empty strings, control characters, and unicode correctly, where hand-quoting silently mangles all three.
// Good
return fmt.Errorf("parse %q: %w", raw, err)
// Bad
return fmt.Errorf("parse \"%s\": %w", raw, err)- This matters most for human-facing output where the input may be empty, contain whitespace, or contain control characters.
Enforcement: review; %q for quoting string values in format strings.
Reasoning, step by step:
- Implement
fmt.Stringeron types that benefit from a human-readable representation.%sand%vuse it, so logging and debugging output stays legible.
type Money struct {
Amount string
Currency string
}
func (m Money) String() string {
return m.Amount + " " + m.Currency
}- Choose the formatting verb deliberately:
%vfor default formatting,%+vfor struct field names,%#vfor Go syntax, and%Tfor the type name. These are valuable in logging and debugging.
Enforcement: review; fmt.Stringer on types with a meaningful human-readable form.
Reasoning, step by step:
- Always use field names in struct literals. Positional initialization breaks silently when fields are added or reordered.
// Good -- field names make intent explicit
hotel := Hotel{
ID: "123",
Name: "Grand Hotel",
Rating: 4.5,
}
// Bad -- positional, breaks if a field is inserted
hotel := Hotel{"123", "Grand Hotel", 4.5}- Omit zero-value fields unless they add meaningful context. In test tables, explicit zero values can improve readability.
// Good -- zero values omitted in production code
config := ClientConfig{
BaseURL: "https://api.example.com",
Timeout: 30 * time.Second,
// Retries defaults to 0, which is correct
}
// Good -- explicit zero in test table for clarity
{
name: "no retries",
config: ClientConfig{Retries: 0},
wantErr: true,
}- For empty struct declarations, use
varinstead of an empty literal — the empty value-type literalT{}adds no information.
// Good
var user User
// Bad -- empty literal adds no information
user := User{}Enforcement: review; field-named struct literals, var over empty T{} literals.
Reasoning, step by step:
- Use
make()for maps that will be populated programmatically, and map literals for maps with a fixed set of elements. An empty literalmap[K]V{}for a programmatic map is the wrong tool — saymake().
// Good -- populated programmatically
seen := make(map[string]bool)
for _, item := range items {
seen[item.ID] = true
}
// Good -- fixed elements
statusText := map[int]string{
200: "OK",
404: "Not Found",
500: "Internal Server Error",
}
// Bad -- empty literal for a programmatic map
seen := map[string]bool{}- When the map size is known or estimable, provide a capacity hint to reduce allocations.
// Good -- capacity hint reduces allocations
lookup := make(map[string]*Hotel, len(hotels))
for _, h := range hotels {
lookup[h.ID] = h
}- Declare nil maps for maps that are only read (e.g., used in a lookup). Nil maps behave identically to empty maps for reads but panic on writes — this catches accidental mutation.
Enforcement: review; make() for programmatic maps, literals for fixed sets, capacity hints where the size is known.
Reasoning, step by step:
- Prefer composite literals over
new()followed by field assignment. The literal states the whole value in one expression instead of mutating it field by field.
// Good
hotel := &Hotel{
ID: "123",
Name: "Grand Hotel",
Rating: 4.5,
}
// Bad
hotel := new(Hotel)
hotel.ID = "123"
hotel.Name = "Grand Hotel"
hotel.Rating = 4.5- For slices and maps, provide size hints when the size is known or estimable, so the value does not grow through multiple reallocations.
// Good -- pre-allocates capacity
names := make([]string, 0, len(hotels))
for _, h := range hotels {
names = append(names, h.Name)
}
// Bad -- grows dynamically through multiple allocations
var names []string
for _, h := range hotels {
names = append(names, h.Name)
}Enforcement: review; composite literals over new(), size hints for slices and maps with known length.
Reasoning, step by step:
- Declare format strings as
constwhen they are used outside of a directfmt.*call. This letsgo vetperform static analysis on the format string; avarformat string is opaque to the analyzer.
// Good -- go vet can analyze the format
const userFormat = "user %s (ID: %d)"
log.Printf(userFormat, name, id)
// Bad -- format string in a variable, go vet cannot check
var userFormat = "user %s (ID: %d)"
log.Printf(userFormat, name, id)- This matters most for logging and error formatting, where a mismatched format verb is a runtime bug, not a compile-time error.
Enforcement: go vet; const format strings used outside direct fmt.* calls.
- Zero values via pointer composite literals (
&T{}), struct construction, and declaration idioms: 12 - Variables and Declarations. - Error wrapping with
%wandfmt.Errorf: 03 - Error Handling. - API boundaries and DTO design: 05 - API Design.