Project challenges / verified progress
Beacon: turn a program into a service

The engineering notebook

Shape the JSON contract

Why is an API response a promise rather than whatever your structs look like today?

Loading statusStage 6 of 10

  • Workspace not ready
  • Agent not ready
Focus25:00
A small focus ritual

0 focus sessions completed. Every fourth session offers a longer break. Start each phase when you are ready.

Study time never unlocks verified lesson progress.

Loading...

Loading verified progress...

Loading GitHub account...
Phase 3 — API and observability

Step 01 of 06

Learn the concept

The first client that parses your JSON turns a field name into a promise. Rename an internal field casually and you have created a breaking change with better indentation. Contracts deserve their own types.

API TYPES HIDE INTERNALSJSON responsestable client shapeDTOtags and versionsDomain resultservice truthDatabase rowstorage details
The API type is not busywork. It is the adapter that lets storage change, prober fields grow, and clients keep receiving the contract they were promised.
Step 01

The ideas this is made of

Struct tags are contract names

Go exports StartedAt; JSON clients expect startedAt. A tag like json:"startedAt" states the wire name explicitly. Without tags, Go's field names leak into the API. That may be acceptable for a throwaway script. Beacon is not a throwaway script.

`omitempty` can erase important truth

omitempty removes zero values, empty strings, nil slices and false booleans. That is useful for optional fields such as error. It is dangerous for statusCode if 0 means no HTTP response existed and clients need to distinguish it from an omitted field. Use pointers when absence and zero are different facts.

Time formatting is a boundary decision

Go encodes time.Time as RFC3339 by default, such as 2026-10-05T16:43:00Z. Keep UTC at the boundary and avoid local display formats in the API. Humans can render time zones later. Machines need one unambiguous instant.

Path versioning buys room

/v1/results says the response shape is version one. A future incompatible shape can live at /v2 while older clients keep working. Versioning every field is not needed. Versioning the path early is a small cost that avoids negotiation during an outage.

A response with a stable time
package main

import (
	"encoding/json"
	"os"
	"time"
)

type Response struct {
	CheckedAt time.Time `json:"checkedAt"`
	Error     string    `json:"error,omitempty"`
}

func main() {
	_ = json.NewEncoder(os.Stdout).Encode(Response{CheckedAt: time.Unix(0, 0).UTC()})
}

The zero Unix instant appears as an RFC3339 string and error is absent because it is optional. That absence is now part of the contract.

Internal versus API type

TypeOptimized forCan change?

Prober result

Measurement

With code

Database row

Storage query

With migration

API DTO

Client contract

Only by version

Log record

Operations search

Independently

What these are called on the job

  • DTO — Data transfer object: a type shaped for crossing a boundary.

  • Wire format — The exact bytes and field names clients receive.

  • Backward compatible — A change old clients can accept without modification.