10. API layer

Everything up to this chapter has been assembled but never actually reachable from outside the process. This chapter wires it to a real transport: routes, request and response shapes, and the middleware that runs on every request. By the end, curl will be able to log in, create an order, and read it back — for real, against everything built in chapters 5 through 9.

This chapter uses the standard library’s net/http, which needs no dependency and is enough for everything here. If you would rather use a router, or need gRPC, the next two chapters implement the identical API without touching anything below the transport:

Both are optional: the service is complete with net/http alone, and chapter 13 follows on from this one whether you read them or not. The request shapes, the domain-error mapping and the middleware reasoning are the same in all three, and both chapters assume this one.

Define the request and response shapes first

The API’s JSON shapes are their own thing, separate from domain.Order — a domain type can gain a field for internal reasons without that automatically becoming part of the public API contract. Create transport/api/dto.go:

package api

import (
	"time"
	"uuid"

	"example.com/servoorders/internal/domain"
)

type loginRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

type loginResponse struct {
	Token string `json:"token"`
}

type createOrderRequest struct {
	Item     string `json:"item"`
	Quantity int    `json:"quantity"`
}

type orderResponse struct {
	ID        uuid.UUID `json:"id"`
	Item      string    `json:"item"`
	Quantity  int       `json:"quantity"`
	Status    string    `json:"status"`
	CreatedAt time.Time `json:"created_at"`
}

func newOrderResponse(o *domain.Order) orderResponse {
	return orderResponse{
		ID:        o.ID,
		Item:      o.Item,
		Quantity:  o.Quantity,
		Status:    string(o.Status),
		CreatedAt: o.CreatedAt,
	}
}

type listOrdersResponse struct {
	Orders []orderResponse `json:"orders"`
}

type errorResponse struct {
	Error string `json:"error"`
}

Every one of these types is unexported — nothing outside this package needs to construct a loginRequest directly, and keeping them private means changing the wire format later can’t accidentally become a breaking change for Go code that imported them.

One place turns a domain error into a status

The transport is where a domain sentinel error becomes a status the caller understands, and nothing below it knows what a status is. That is why chapter 4 defines ErrNotFound and ErrForbidden rather than constants named after HTTP codes: the service layer would otherwise have to know it is being called over HTTP — which, on the gRPC transport, it is not.

Domain error HTTP status gRPC code
ErrNotFound 404 codes.NotFound
ErrForbidden 403 codes.PermissionDenied
ErrInvalidCredentials 401 codes.Unauthenticated
ErrValidation 400 codes.InvalidArgument
anything else 500 codes.Internal

writeDomainError below is this chapter’s implementation. The other two transports have the same switch under a different name, and that is the only thing about error handling that changes between them.

Build the middleware chain

Two cross-cutting concerns apply to (almost) every request: authentication, and not letting a panic in one handler take down every other in-flight request. Create transport/api/middleware.go. Start with how a verified request identifies its caller — a context.Context key, and the two functions that use it:

package api

import (
	"context"
	"net/http"
	"strings"

	"example.com/servoorders/internal/auth"
	"example.com/servoorders/internal/observability"
)

type contextKey int

const claimsKey contextKey = 0

func requireAuth(issuer *auth.Issuer, next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		header := r.Header.Get("Authorization")
		token, ok := strings.CutPrefix(header, "Bearer ")
		if !ok || token == "" {
			writeError(w, http.StatusUnauthorized, "missing or malformed Authorization header")
			return
		}

		claims, err := issuer.Verify(token)
		if err != nil {
			writeError(w, http.StatusUnauthorized, "invalid or expired token")
			return
		}

		ctx := context.WithValue(r.Context(), claimsKey, claims)
		next(w, r.WithContext(ctx))
	}
}

func claimsFromContext(ctx context.Context) (auth.Claims, bool) {
	claims, ok := ctx.Value(claimsKey).(auth.Claims)
	return claims, ok
}

requireAuth wraps a single handler (it’s called per-route below, not registered globally) — the login endpoint has no token to check yet, so it can’t go through this. Notice this is the only place that knows about the Authorization header or the 401 response shape; auth.Verify itself, from chapter 9, never heard of HTTP. That split is deliberate: the JWT logic stays reusable from a transport other than HTTP without any change.

The other two middlewares apply to every request, so they wrap the whole handler rather than one route at a time:

func recoverMiddleware(log *observability.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if rec := recover(); rec != nil {
				log.ErrorContext(r.Context(), "api: panic recovered", "panic", rec, "path", r.URL.Path)
				writeError(w, http.StatusInternalServerError, "internal server error")
			}
		}()
		next.ServeHTTP(w, r)
	})
}

func loggingMiddleware(log *observability.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(sw, r)
		log.InfoContext(r.Context(), "request",
			"method", r.Method, "path", r.URL.Path, "status", sw.status)
	})
}

type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(status int) {
	w.status = status
	w.ResponseWriter.WriteHeader(status)
}

statusWriter exists because http.ResponseWriter doesn’t expose what status code a handler already wrote — wrapping it is the standard way to capture that for logging. This logging is intentionally bare-bones; chapter 15 replaces it with something that correlates each line to a trace, using the same wrapper.

Write the handlers

Create transport/api/handlers.go. handleLogin is the simplest one — decode, delegate to service.AuthService, map whatever comes back:

package api

import (
	"encoding/json"
	"errors"
	"net/http"
	"strconv"
	"uuid"

	"example.com/servoorders/internal/domain"
)

func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
	var req loginRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}

	token, err := s.auth.Login(r.Context(), req.Username, req.Password)
	if err != nil {
		writeDomainError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, loginResponse{Token: token})
}

writeDomainError is worth building next, since every other handler uses it too — this is the one place in the whole service that turns a domain sentinel error into an HTTP status code:

func writeDomainError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, domain.ErrNotFound):
		writeError(w, http.StatusNotFound, "not found")
	case errors.Is(err, domain.ErrForbidden):
		writeError(w, http.StatusForbidden, "forbidden")
	case errors.Is(err, domain.ErrInvalidCredentials):
		writeError(w, http.StatusUnauthorized, "invalid credentials")
	case errors.Is(err, domain.ErrValidation):
		writeError(w, http.StatusBadRequest, err.Error())
	default:
		writeError(w, http.StatusInternalServerError, "internal server error")
	}
}

func writeError(w http.ResponseWriter, status int, message string) {
	writeJSON(w, status, errorResponse{Error: message})
}

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}

Every handler below this point only ever returns a domain error upward — never an http.StatusCode of its own — which is exactly the boundary chapter 4 described before either side existed. The three order handlers follow: create, get (with the authorization check happening on whatever the service layer actually returns, not before), and list:

func (s *Server) handleCreateOrder(w http.ResponseWriter, r *http.Request) {
	claims, _ := claimsFromContext(r.Context()) // requireAuth guarantees this is present

	var req createOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}

	order, err := s.orders.CreateOrder(r.Context(), claims.UserID, req.Item, req.Quantity)
	if err != nil {
		writeDomainError(w, err)
		return
	}
	writeJSON(w, http.StatusCreated, newOrderResponse(order))
}

func (s *Server) handleGetOrder(w http.ResponseWriter, r *http.Request) {
	claims, _ := claimsFromContext(r.Context())

	id, err := uuid.Parse(r.PathValue("id"))
	if err != nil {
		writeError(w, http.StatusBadRequest, "invalid order id")
		return
	}

	order, err := s.orders.GetOrder(r.Context(), claims.UserID, id)
	if err != nil {
		writeDomainError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, newOrderResponse(order))
}

Notice what handleGetOrder does not do: it never checks whether order.UserID matches claims.UserID itself. That check lives in authorize back in chapter 8, one layer down — the API layer’s job stops at translating whatever domain error comes back into a status code. When the order belongs to someone else, that’s domain.ErrForbidden, which writeDomainError maps to 403, not 404. That’s a deliberate, debatable choice, not an oversight: a 403 confirms the order exists, just not for this caller; a 404 would hide even that much. For a resource where existence itself is sensitive — someone else’s private message thread, say, rather than an order ID nobody could guess anyway — returning 404 for “not yours” and for “doesn’t exist” alike is the more defensible default, at the cost of a slightly worse error message for legitimate callers who mistyped an ID. This tutorial uses 403 because an order ID is an opaque UUID with nothing worth hiding behind it; don’t copy that choice onto a resource where it doesn’t hold.

r.PathValue("id") is Go 1.22+’s stdlib router reading {id} out of the route pattern registered below — no third-party router needed for this. handleListOrders adds pagination, clamped rather than trusted:

const (
	defaultListLimit = 20
	maxListLimit     = 100
)

func (s *Server) handleListOrders(w http.ResponseWriter, r *http.Request) {
	claims, _ := claimsFromContext(r.Context())

	limit := parseIntParam(r, "limit", defaultListLimit)
	if limit <= 0 || limit > maxListLimit {
		limit = defaultListLimit
	}
	offset := max(parseIntParam(r, "offset", 0), 0)

	orders, err := s.orders.ListOrders(r.Context(), claims.UserID, limit, offset)
	if err != nil {
		writeDomainError(w, err)
		return
	}

	resp := listOrdersResponse{Orders: make([]orderResponse, len(orders))}
	for i, o := range orders {
		resp.Orders[i] = newOrderResponse(o)
	}
	writeJSON(w, http.StatusOK, resp)
}

func parseIntParam(r *http.Request, name string, fallback int) int {
	raw := r.URL.Query().Get(name)
	if raw == "" {
		return fallback
	}
	n, err := strconv.Atoi(raw)
	if err != nil {
		return fallback
	}
	return n
}

A ?limit=100000 or ?limit=-5 from a client silently clamps to something reasonable instead of either erroring or, worse, actually trying to return an unbounded result set.

Wire the routes and build the server

Create transport/api/server.go:

package api

import (
	"context"
	"errors"
	"fmt"
	"net/http"

	"example.com/servoorders/internal/auth"
	"example.com/servoorders/internal/config"
	"example.com/servoorders/internal/observability"
	"example.com/servoorders/internal/service"
)

type Server struct {
	http   *http.Server
	orders *service.OrderService
	auth   *service.AuthService
}

// HTTPAddr and AdminAddr take no prefix: both are spelled that way in
// every deployment already. AdminAddr belongs to this package because it
// is the same concern — serving HTTP — even though main binds that
// listener rather than the graph; see chapter 15.
type Config struct {
	HTTPAddr  string `env:"HTTP_ADDR" envDefault:":8080"`
	AdminAddr string `env:"ADMIN_ADDR" envDefault:":8081"`
}

func NewConfig(src config.Source) (*Config, error) {
	return config.Parse[Config](src, "")
}

func New(
	cfg *Config,
	orders *service.OrderService,
	authSvc *service.AuthService,
	issuer *auth.Issuer,
	log *observability.Logger,
) *Server {
	s := &Server{orders: orders, auth: authSvc}

	mux := http.NewServeMux()
	mux.HandleFunc("POST /auth/login", s.handleLogin)
	mux.HandleFunc("POST /orders", requireAuth(issuer, s.handleCreateOrder))
	mux.HandleFunc("GET /orders/{id}", requireAuth(issuer, s.handleGetOrder))
	mux.HandleFunc("GET /orders", requireAuth(issuer, s.handleListOrders))

	s.http = &http.Server{
		Addr:    cfg.HTTPAddr,
		Handler: recoverMiddleware(log, loggingMiddleware(log, mux)),
	}
	return s
}

"POST /auth/login" — method and pattern in one string — is Go 1.22+’s stdlib http.ServeMux. It’s enough for four routes with no path-parameter conflicts, so there’s no third-party router to introduce or explain; see chapter 21 for when one earns its keep.

Run and Stop — and a bug worth hitting on purpose

Every component so far has gotten Run/Stop/Health more or less for free — a thin wrapper around something with an obvious “connect” and “disconnect.” An *http.Server looks like it should be just as simple:

func (s *Server) Run(ctx context.Context) error {
	if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		return fmt.Errorf("api: %w", err)
	}
	return nil
}

func (s *Server) Stop(ctx context.Context) error {
	return s.http.Shutdown(ctx)
}

This compiles, and it even works — right up until the process needs to actually shut down. Sending it a real SIGTERM hangs forever. Here’s why, and it’s worth understanding rather than just copying the fix: servo’s generated App.Run waits for every Runner via an errgroup, and only calls Shutdown (which calls Stop, which calls http.Server.Shutdown) once every Runner has already returned:

func (a *App) Run(ctx context.Context) error {
	g, gctx := errgroup.WithContext(ctx)
	g.Go(func() error { return a.server.Run(gctx) })
	g.Go(func() error { return a.notifier.Run(gctx) })
	return g.Wait()
}

ListenAndServe never observes ctx cancellation on its own — it only returns once something calls Shutdown or Close on the server. So with the naive Run above: ctx gets cancelled, Run doesn’t notice, ListenAndServe keeps blocking, errgroup.Wait() never returns, App.Run never returns, and main() — which calls app.Shutdown() only after app.Run() returns — never reaches the line that would have called Stop. Everything is waiting for something that’s waiting for it.

The fix is for Run to watch ctx itself, and simply stop waiting once it’s cancelled — Stop still does the actual socket close, moments later, once Shutdown is finally reached:

func (s *Server) Run(ctx context.Context) error {
	errCh := make(chan error, 1)
	go func() {
		if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- fmt.Errorf("api: %w", err)
			return
		}
		errCh <- nil
	}()

	select {
	case <-ctx.Done():
		return nil
	case err := <-errCh:
		return err
	}
}

notifier.Run, back in chapter 7, already had the correct shape (<-ctx.Done(); return nil) — the lesson generalizes: any Run your own code writes must return when its context is cancelled, on its own. Nothing external can force it to; errgroup only waits, it never interrupts. A regression test pins this down by never calling Stop at all:

func TestRunReturnsPromptlyWhenContextIsCancelled(t *testing.T) {
	// ... construct srv with mocked dependencies ...
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan error, 1)
	go func() { done <- srv.Run(ctx) }()

	cancel()
	select {
	case err := <-done:
		if err != nil {
			t.Errorf("Run returned %v, want nil", err)
		}
	case <-time.After(2 * time.Second):
		t.Fatal("Run did not return within 2s of context cancellation")
	}
}

Against the naive version, this test fails exactly as predicted: Run did not return within 2s of context cancellation. Against the fixed version, it passes in under a millisecond. If Run ever regresses to needing Stop to unblock it, this test — not a production incident — is what catches it.

Integrating with servo

Nothing in api/ imports servo, and nothing has to. The server is an ordinary constructor taking ordinary dependencies, so naming it as a root is the entire integration — cmd/orders/spec.go, covered properly in chapter 13:

servo.Build(
	servo.Root[*api.Server](),
	// ... binds, scope, overrides
)

servo generate works backwards from that: api.New needs a *service.OrderService, which needs a repository and a cache, and so on down. Run and Stop are found structurally — no interface to implement, no registration — so the generated App.Run starts this server and App.Shutdown stops it, in dependency order.

That is the whole of it, and it is the same three lines for the two transports in the next two chapters.

What app.Ready actually reports

Health and Ready are two different questions, and an orchestrator does two very different things with the answers. Health means “this process is not broken” — a failure gets the container restarted. Ready means “send me traffic” — a failure only takes the instance out of the load balancer, and is expected to be temporary. Conflating them is how a slow warm-up turns into a restart loop.

This server has an honest answer to the second question, and giving it requires one change to Run. ListenAndServe hides the moment the port is bound inside itself, so bind explicitly:

func (s *Server) Run(ctx context.Context) error {
	ln, err := net.Listen("tcp", s.http.Addr)
	if err != nil {
		return fmt.Errorf("api: listen: %w", err)
	}
	s.ready.Store(true)
	// ... serve on ln, return when ctx is done
}

// Ready reports whether this server is accepting connections yet.
func (s *Server) Ready(context.Context) error {
	if !s.ready.Load() {
		return errors.New("api: not accepting connections yet")
	}
	return nil
}

ready is an atomic.Bool on the struct: written by Run, read by whatever goroutine serves the probe. The window it describes is real — between the graph finishing construction and Run binding the port, the process is perfectly healthy and cannot serve a single request. Routing traffic there produces connection refused for no reason at all.

That is the whole of Readier. Nothing calls it for you: servo emits app.Ready(ctx), and you decide when it runs — which is what the next section wires up.

Health and readiness live outside the graph

GET /healthz and GET /readyz need app.Health(ctx) and app.Ready(ctx) — but those are methods on the fully-constructed App, and nothing inside the graph (including api.Server itself) can get a reference to App, because App doesn’t exist yet at the point any single component inside it is being built. This isn’t a limitation to work around with a clever constructor trick; it’s just outside what dependency injection is for. The straightforward answer is to wire these two routes by hand, on a separate listener from the API’s own — which also means health checks never compete with real traffic for the same connection pool.

It goes in its own package, transport/admin/, rather than in main.go. That looks like over-engineering for two routes until you notice the two companion transports need exactly the same thing: three copies of a security boundary is three chances to get one wrong. admin.New takes an interface rather than a concrete *App precisely so one implementation serves every injector.

// transport/admin/admin.go
package admin

// Checker is the part of a generated servo App this package needs. Taking
// an interface rather than *App is what lets one implementation serve
// every injector, each of which generates its own App type.
type Checker interface {
	Health(context.Context) servo.Report
	Ready(context.Context) servo.Report
}

func New(addr string, app Checker, metrics http.Handler) *http.Server {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", reportHandler(app.Health))
	mux.HandleFunc("GET /readyz", reportHandler(app.Ready))
	mux.Handle("GET /metrics", metrics)
	return &http.Server{Addr: addr, Handler: mux}
}

The metrics handler is chapter 15’s; it is on this listener for exactly the same reason the health routes are. Everything served here describes the service’s internals — /healthz names every component in the graph along with its status — which is why the deployment binds this listener to the cluster network and never to the internet.

reportHandler renders a servo.Report as JSON — but not by encoding it directly. servo.NodeStatus has a String() method, but encoding/json only ever calls MarshalJSON/MarshalText, neither of which it implements, so a bare json.Marshal(report) prints "Status":0 instead of "Status":"ok". Re-rendering it into a small local type fixes that:

type response struct {
	Clean bool         `json:"clean"`
	Nodes []node `json:"nodes"`
}

type node struct {
	Name   string `json:"name"`
	Status string `json:"status"`
	Error  string `json:"error,omitempty"`
}

func reportHandler(check func(context.Context) servo.Report) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		report := check(r.Context())

		resp := response{Clean: report.Clean()}
		for _, n := range report.Nodes {
			node := node{Name: n.Name, Status: n.Status.String()}
			if n.Err != nil {
				node.Error = n.Err.Error()
			}
			resp.Nodes = append(resp.Nodes, node)
		}

		w.Header().Set("Content-Type", "application/json")
		if !report.Clean() {
			w.WriteHeader(http.StatusServiceUnavailable)
		}
		json.NewEncoder(w).Encode(resp)
	}
}

AdminAddr is already on the transport’s own Config, alongside HTTPAddr — the listener is wired in main rather than by the graph, but the address belongs to the same concern.

These three endpoints stay off the public port

That separation is the point, and it is worth stating plainly: /healthz and /readyz enumerate every component in the graph by name along with its status, and /metrics (chapter 15) exposes request rates, latencies and error counts per route. Together they describe the shape and health of the system precisely enough to be worth hiding, so the deployment binds this listener to the cluster network and no ingress rule points at it.

Boundaries that live only in someone’s memory erode, so this is asserted rather than assumed — here, and in both companion transports:

for _, path := range []string{"/healthz", "/readyz", "/metrics"} {
	resp, err := http.Get(ts.URL + path)
	...
	if resp.StatusCode != http.StatusNotFound {
		t.Errorf("GET %s = %d on the public listener, want 404", path, resp.StatusCode)
	}
}

Adding /metrics to a public router — the kind of change that looks harmless in review — fails that test.

Try the whole thing

Set every required variable and start the service:

$ POSTGRES_DSN="postgres://orders:orders@localhost:5432/orders?sslmode=disable" \
  REDIS_ADDR="localhost:6379" \
  NATS_URL="nats://localhost:4222" \
  JWT_SECRET="dev-secret-do-not-use-in-production" \
  go run ./cmd/orders
$ curl -s -X POST http://localhost:8080/auth/login -d '{"username":"alice","password":"password123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...YfoSyVgGFq_A4pl5vS9Kr9maUm_p6gl7Ngjp4twzXb0"}
$ TOKEN=<the token above>
$ curl -s -X POST http://localhost:8080/orders -H "Authorization: Bearer $TOKEN" -d '{"item":"widget","quantity":3}'
{"id":"fc532002-fdd4-4874-a8ae-47a4b8aa0b3d","item":"widget","quantity":3,"status":"pending","created_at":"2026-08-27T12:09:16.182727Z"}
$ curl -s http://localhost:8080/orders/fc532002-fdd4-4874-a8ae-47a4b8aa0b3d -H "Authorization: Bearer $TOKEN"
{"id":"fc532002-fdd4-4874-a8ae-47a4b8aa0b3d","item":"widget","quantity":3,"status":"pending","created_at":"2026-08-27T12:09:16.182727Z"}

$ curl -s http://localhost:8080/orders -H "Authorization: Bearer $TOKEN"
{"orders":[{"id":"fc532002-fdd4-4874-a8ae-47a4b8aa0b3d","item":"widget","quantity":3,"status":"pending","created_at":"2026-08-27T14:09:16.182727+02:00"}]}

And the admin port:

$ curl -s http://localhost:8081/healthz
{"clean":true,"nodes":[{"name":"*example.com/servoorders/internal/repository/postgres.Store","status":"ok"},{"name":"*example.com/servoorders/internal/cache/redis.Cache","status":"ok"},{"name":"*example.com/servoorders/internal/broker/natsbroker.Publisher","status":"ok"}]}

/readyz responds too, but with an empty node list ({"clean":true,"nodes":null}) — nothing in this graph implements Readier yet, so there’s nothing distinct from Health for it to report. That’s not a bug to fix; it’s what “no component needs a separately-meaningful readiness signal” honestly looks like. Chapter 13 covers every capability this graph actually uses, side by side.

Write it down: openapi/openapi.yaml

Everything above was verified by hand, with curl, one endpoint at a time. That’s enough to prove it works; it isn’t enough for someone integrating against this API to discover what it promises without reading the handler source. transport/openapi/openapi.yaml writes the same contract down in a form tooling can consume — client generators, Try it out-style documentation viewers, contract-testing tools. One operation out of the full spec, GET /orders/{id}, shown here (trimmed of its own trailing content:/schema: blocks and the shared 429 every operation in the real file also declares — see the file itself for those):

paths:
  /orders/{id}:
    get:
      operationId: getOrder
      summary: Get a single order by ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The order
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Order"
        "400":
          description: id is not a valid UUID
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          description: Missing, malformed, or expired bearer token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "403":
          description: >
            The order exists but belongs to a different user. Returned
            instead of 404 only because this tutorial's authorization
            model has nothing to hide the order's existence *for* — see
            docs/tutorial/10-api-layer.md's note on when 404 would be the
            more defensible choice instead.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No order with that ID exists

Every schema in the components: section is a direct transcription of the real DTOs in transport/api/dto.goOrder mirrors orderResponse field for field, right down to status being constrained to the single value pending this service ever actually assigns, rather than an aspirational enum of statuses nothing here implements yet. A spec that describes a richer API than the code actually serves is worse than no spec at all: it fails silently, by lying, exactly where a missing spec at least fails honestly by not existing.

The spec deliberately covers only the business API on HTTPAddr (port 8080) — /healthz, /readyz, and /metrics on AdminAddr are operational surface for load balancers and Prometheus, not part of the contract an API consumer integrates against, and none of them take a request body worth documenting beyond “GET it, read the status code.” Validate the spec itself the same way you’d validate any other generated artifact, rather than trusting it by inspection:

$ npx @redocly/cli lint openapi/openapi.yaml
...
openapi/openapi.yaml: validated in 17ms

Woohoo! Your API description is valid. 🎉
You have 2 warnings.

The two remaining warnings (no license in info, and a localhost server URL) are both intentional for a tutorial spec, not oversights left unfixed.

Serving it, and where

A spec that lives only in the repository drifts from the service. transport/openapi/openapi.go embeds it and serves both the raw document and a browser UI, and all three transports mount the same handler:

//go:embed openapi.yaml
var Spec []byte

// Handler serves /openapi.yaml and /swagger/.
func Handler() http.Handler { ... }

Embedding means the binary and its documentation cannot drift apart in a deployment — there is no file to forget to copy into the image.

It is mounted on the public listener, beside the API it describes, which is a deliberate split from the three endpoints above: the contract tells a caller how to use endpoints they are already allowed to reach, while health and metrics describe the service’s internals. Two more lines at the top of api.New’s mux, above the routes it documents:

mux.Handle("GET /openapi.yaml", openapi.Handler())
mux.Handle("GET /swagger/", openapi.Handler())

That is a defensible default and not the only one. Publishing the UI publishes your full endpoint list, DTO shapes and auth scheme — fine for a public API, not fine for an internal one whose surface is itself sensitive. openapi.Handler() is an ordinary http.Handler, so moving it is one line: mount it in admin.New instead, and consumers fetch the spec from inside the cluster or get it handed to them out of band.

One caveat worth knowing: the UI loads Swagger UI’s JavaScript from a CDN rather than vendoring several megabytes into the repository. A browser with no route to the internet gets an empty page, and the spec itself is still readable at /openapi.yaml. A service that must document itself in an air-gapped network should vendor the assets and serve them from transport/openapi/.

Diagnostics

  • A client hangs waiting for the process to exit after Ctrl+C — this chapter’s own bug. Check every Run you write blocks on <-ctx.Done() (directly, or via a select like api.Server’s above) rather than only on something that has no idea ctx exists.
  • missing or malformed Authorization header on a request you’re sure has a token — the header value must be exactly Bearer <token>, one space, case-sensitive on Bearer. A common mistake: sending just the raw token with no Bearer prefix at all.
  • A panic in one handler seems to take the whole server down anyway — confirm recoverMiddleware is actually the outermost layer (recoverMiddleware(log, loggingMiddleware(log, mux)), not the other way around) — a panic inside loggingMiddleware itself, outside the recover, would still escape.

Do’s and don’ts

  • Do validate and clamp query parameters (limit, offset) rather than trusting them — handleListOrders never lets a client force an unbounded query.
  • Do keep DTOs unexported and distinct from domain types, even when they look identical today. The day they diverge (a field the API should hide, a field the API should rename) is much easier if that boundary already exists.
  • Don’t let a handler construct a domain.Order (or any domain type) by hand and pass it straight to the repository — every write goes through the service layer, which is where validation and orchestration live. A handler’s job is translating HTTP into a service call and back, nothing more.
  • Don’t log a request body that might contain a password — handleLogin’s loginRequest never gets logged whole anywhere in this codebase; only the method, path, and status do.

Next

Chapter 13: Wiring with servo — putting every layer built so far into one spec file and letting servo generate do the rest.