Table of contents
Parts I to IV covered the machinery a service needs before it does anything for
anybody. The reference implementation carries one feature on top of it: four
endpoints over a table of items, in internal/item and
internal/httphandlerpub/item.go.
Part V reads that feature, then covers what it leaves out. Every snippet in these four pages is code from the generated tree, at the path named above it. Where a section goes past what ships, it opens by saying so.
The resource
An item: a named thing, a quantity held of it, and when it was recorded.
{
"id": "019813a0-0000-7000-8000-000000000001",
"name": "seed-bolt",
"quantity": 17,
"created_at": "2025-01-01T00:00:01.000001Z"
}
Four endpoints, all on the public listener:
POST /items create one
GET /items list a page, newest first
GET /items/:id fetch one
DELETE /items/:id remove one
No update, no filters, no paging beyond a limit. The feature is deliberately at the floor of what counts as a working CRUD API, which makes it short enough to read whole and leaves the harder decisions visible as decisions rather than as code you inherit. Pages 15 and 16 take each of them in turn.
Contract first
The OpenAPI document is a design step rather than documentation. Writing the operation out is where you find that you have not decided whether a limit above the maximum is an error or a smaller page, or what a client gets when the feature is switched off.
The project has three documents, one per listener:
openapi_monitoring.yaml the operational endpoints
openapi_private.yaml internal operations
openapi_public.yaml what the outside world sees
The split matches the listeners rather than dividing one file by tags. An
endpoint appears in exactly one document, and which one is the decision
page 17 is about. A single file with an
x-internal tag makes the same decision in a way that can be got wrong by
omission.
The items are in openapi_public.yaml:
/items:
post:
tags:
- items
summary: Creates an item
description: >-
Available only when the service is configured with a database;
otherwise the route is not registered and the request returns 404.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/NewItem'
responses:
'201':
description: The item was created
headers:
Location:
description: Path of the created item
schema:
type: string
content:
application/json:
schema:
$ref: '#/components/schemas/Item'
'400':
description: The request body is not valid JSON
'409':
description: An item with the same name already exists
'422':
description: The item name is empty or too long
Abridged: each failure response also declares a text/plain body, the thing
SendStatus writes on page 15.
Every status code the handler can produce is listed, including the 400 that is easy to forget: a generated client that does not know 400 is possible has no branch for it, and page 18’s fuzzer reports the omission as a failure.
The payload schema is where the bounds are published:
NewItem:
type: object
additionalProperties: false
required:
- name
properties:
name:
type: string
minLength: 1
maxLength: 128
quantity:
type: integer
minimum: 0
maximum: 4294967295
default: 0
additionalProperties: false is the specification’s half of the strict decoding
on page 15, and the three numbers are
the column widths written down where a client can read them. The description
on each operation carries the disabled case, because a 404 from an unregistered
route is otherwise indistinguishable from a typo in the path.
The schema
resources/db/mysql/schema/V0000__schema.sql:
CREATE TABLE IF NOT EXISTS item (
id CHAR(36) NOT NULL,
name VARCHAR(128) NOT NULL,
quantity INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_item_name (name),
KEY idx_item_created_at (created_at, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The identifier is a CHAR(36) rather than an auto-increment column because the
service generates it, a UUIDv7 from random.Rnd. A client that sends a request
and loses the response knows the identifier it asked about only if the service
did not invent it, and a sequence leaks how many rows exist and lets anybody
walk them by counting.
DATETIME(6) keeps microseconds. The list on
page 16 orders on created_at, and
whole seconds would make two items recorded in the same second
indistinguishable by it, so the ordering would vary between two runs of the same
query.
uk_item_name is the mechanism behind the 409. The service does not check
whether a name is taken before inserting, because a check and an insert are two
statements and another request fits between them; the index refuses the second
row whatever the timing, and the repository turns that refusal into
ErrConflict.
idx_item_created_at carries the identifier alongside the timestamp, so the
list query’s ORDER BY created_at DESC, id DESC is answered from the index
rather than sorted afterwards.
Three layers, and the seams between them
internal/httphandlerpub/item.go HTTP: decode, map errors, write status codes
internal/item/service.go rules: names, limits, identifiers, timestamps
internal/item/repository.go SQL: the only file naming a column
internal/item/item.go the types, the limits, the sentinel errors
Each layer knows only the one below it. The handler contains no SQL, the
repository contains no http. anything, and the service contains neither. The
test for whether the seams are in the right place is whether the same feature
could be driven from a queue consumer instead of an HTTP request.
Page 20 comes back to that.
The consumer declares the interface
// internal/httphandlerpub/httphandlerpub.go
// ItemService is the business-logic contract these handlers consume. It is
// declared here, by the consumer, so the handler package depends on a shape it
// controls rather than on a concrete implementation.
type ItemService interface {
Get(ctx context.Context, id string) (*item.Item, error)
List(ctx context.Context, p item.ListParams) ([]item.Item, error)
Create(ctx context.Context, p item.CreateParams) (*item.Item, error)
Delete(ctx context.Context, id string) error
}
*item.Service satisfies it without knowing it exists. The commoner arrangement
is the reverse, with the item package exporting an interface the handler
imports.
An interface declared by the producer describes everything the producer does and grows as it grows, so a consumer ends up depending on methods it never calls and a test double has to implement all of them. An interface declared by the consumer describes what that consumer uses: four methods here, because these handlers call four. A queue consumer added later declares its own two, the service implements the union without either consumer knowing about the other, and a change to a method neither uses breaks neither.
The same pattern one layer down, in the service:
// internal/item/service.go
// Store is the persistence contract the service depends on.
//
// It is declared here, by the consumer, so the service tests run against a
// hand-written fake and never touch a database. The repository in this same
// package is one implementation of it.
type Store interface {
Get(ctx context.Context, id string) (*Item, error)
List(ctx context.Context, limit uint) ([]Item, error)
Create(ctx context.Context, it *Item) error
Delete(ctx context.Context, id string) error
}
*Repository satisfies it, and every service test runs against a fakeStore
with no database anywhere.
The two interfaces are not the same shape, which is the point. ItemService
takes a ListParams, and Store takes the limit the service resolved from it.
Clamping, defaulting and validating happen between them, so the repository
receives a number it can put straight into a LIMIT and has nothing left to
decide.
The types
// internal/item/item.go
// Item is a named thing with a quantity.
//
// Quantity is a uint32 because the column is an INT UNSIGNED: the Go type and
// the SQL type share the same range, so no value that reaches the repository
// can overflow the column.
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
Quantity uint32 `json:"quantity"`
CreatedAt time.Time `json:"created_at"`
}
One type for two jobs, and it is a shortcut worth naming as one. Item is
simultaneously the database row and the wire representation, so adding a column
adds a field to the API and the Scan destinations in the repository have to
keep matching the column list. For four fields that are identical on both sides,
maintaining two types costs more than it returns.
Page 16 is where that stops being true.
The quantity type is doing real work. INT UNSIGNED and uint32 cover exactly
the same range, so the compiler rejects the value the column would have rejected
at insert time, and no quantity that passes the handler can produce a failed
write. The narrowing happens once, at the edge, in
page 15’s itemQuantity.
The payload types are separate
// CreateParams holds the fields accepted when creating an item.
type CreateParams struct {
Name string `json:"name"`
Quantity uint32 `json:"quantity"`
}
// ListParams selects one page of items. A zero Limit means DefaultPageSize.
type ListParams struct {
Limit uint
}
CreateParams rather than Item, because the fields a client may send are not
the fields an item has. Decoding into Item would let a client choose its own
id and backdate created_at, which is mass assignment: the client picks the
primary key, or sets a creation time that reorders the list.
The limits and the sentinels
const (
MaxNameLength = 128
DefaultPageSize = 20
MaxPageSize = 100
)
var (
ErrNotFound = errors.New("item not found")
ErrConflict = errors.New("item already exists")
ErrValidation = errors.New("item validation failed")
)
Each sentinel is an answer a client can act on, and everything else is a 500.
They are declared in the domain package, wrapped with %w at each layer, and
matched with errors.Is in the handler, where
page 10 built the mapping. MaxNameLength
matches the VARCHAR(128) and the maxLength in the specification, which is
the same number in three places and the kind of duplication
page 18’s fuzzer exists to catch.
Handler construction
The handlers are methods on a struct holding the dependencies:
// internal/httphandlerpub/httphandlerpub.go
type HTTPHandlerPublic struct {
service ItemService
httpres *httputil.HTTPResp
logger *slog.Logger
rnd *random.Rnd
}
func (h *HTTPHandlerPublic) handleCreateItem(w http.ResponseWriter, r *http.Request) { ... }
One constructor, dependencies in one place, and BindHTTP next to the handlers
it registers. The risk is a struct that accumulates: at fifteen fields it is a
service locator under another name, and the fix is to split the handler rather
than to change the pattern.
A constructor returning a closure per handler names exactly the dependencies each
one uses, visible in the signature, at the cost of a parameter list per handler
and shared helpers that become free functions. Generic encode and decode
helpers remove the repeated
decode-and-check from every handler and compose with either arrangement; they
are a helper rather than a structure, and worth adding once there are more than
a handful of endpoints.
Versioning
Beyond the example, which serves /items with no version anywhere. Answer this
before the first client, because afterwards the answer is expensive.
A URI prefix, /v1/items, is visible in every log line, routable at a
gateway, cacheable per version, and changed by a client editing a string. Its
critics are right that it versions a resource identifier that has not changed,
and it is what most public APIs do anyway, for the operational reasons.
A header, Accept: application/vnd.example.item+json; version=2, keeps the
URI stable. It is harder to try in a browser, easy to omit, and it needs
Vary: Accept on every response or a cache serves one version’s body to another
version’s client. A media type per resource is the same idea, more formal, and
rarer.
What forces a new version
A change forces a version when a correct existing client would break.
Compatible, no version needed: adding an optional field to a response, adding an optional request field with a default, adding an endpoint, widening what is accepted, or adding a value to an enum a client only echoes back.
Breaking, needs a version: removing or renaming a response field, changing a
field’s type, making an optional request field required, narrowing what is
accepted, changing what a status code means for an existing condition, or
changing the meaning of a value without changing its shape. created_at
switching from UTC to local time is the same JSON and a different API.
Adding a value to an enum a client switches on is the arguable one: compatible if clients tolerate unknown values, breaking if they do not, and you cannot know which without asking. “Compatible” has to mean compatible with a client you do not control, and the safe assumption is that some client switches on your enum with no default case.
Wiring it in
The feature reaches the service in internal/cli/bind.go, inside the helper
that builds the two service binders:
// internal/cli/bind.go
reldb, healthchecks, err := newDatabases(ctx, cfg, l, mtr, wg, sc)
if err != nil {
return nil, nil, nil, err
}
// The item feature needs a database. Without one the public handler is built
// with a nil service and the item routes stay unregistered; see BindHTTP.
//
// itemSvc must be declared as the interface type and left nil: assigning a
// nil *item.Service to it would produce a non-nil interface value, and the
// routes would register against a service that can only fail.
var itemSvc httphandlerpub.ItemService
if reldb.Enabled {
itemSvc = item.NewService(item.NewRepository(reldb.Main.DB(), reldb.Read.DB()))
} else {
l.InfoContext(ctx, "item endpoints disabled: no database configured")
}
serviceBinderPrivate := httphandlerpriv.New(nil, l)
serviceBinderPublic := httphandlerpub.New(itemSvc, l)
Repository over the two connections from
page 12, service over the repository, handler
over the service, in dependency order, in the file that already holds the rest
of the graph. The feature adds no new dependency: database/sql and
sqltransaction are enough, and all the pool configuration and instrumentation
from page 12 applies unchanged.
The comment about the interface type is the trap worth learning once. A Go
interface value holds a type and a pointer, and a nil *item.Service stored in
an ItemService is an interface that is not nil: it has a type. Written the
obvious way, as
var itemSvc *item.Service // wrong: the handler receives a non-nil interface
the h.service == nil test in BindHTTP is false, the routes register, and
every call panics on a nil receiver dereference at the first field access. The
declaration above keeps the interface itself empty, so the check means what it
reads as.
The other half is in the binder:
// internal/httphandlerpub/httphandlerpub.go
func (h *HTTPHandlerPublic) BindHTTP(_ context.Context) []httpserver.Route {
uid := httpserver.Route{ ... }
// The item endpoints need a database. When the service is nil the feature
// is off, and the routes are left unregistered rather than bound to a
// handler that can only fail.
if h.service == nil {
return []httpserver.Route{uid}
}
return append([]httpserver.Route{uid}, h.itemRoutes()...)
}
An absent route answers 404 through the router’s own not-found handler. The
alternative, registering the routes and returning 503 from each, produces an
endpoint that exists, appears in the index, passes a smoke test that only checks
for a response, and fails every real call. Routes are data returned from a
method (page 07), so deciding not to return them
is available in a way it is not when registration is a side effect scattered
through init functions.