Designing the Feature: Contract, Schema, Seams

The item resource the example ships: the OpenAPI operation, the migration, the three layers, the interfaces declared by their consumers, and the switch that decides whether the routes exist at all.


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.


Next: The Write Path: Decode, Validate, Commit.