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:
- Chapter 11: Gin as the transport — route groups and binding-tag validation instead of per-handler wrappers.
- Chapter 12: gRPC as the transport — and serving gRPC and REST from a single port.
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.go — Order 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
Runyou write blocks on<-ctx.Done()(directly, or via aselectlikeapi.Server’s above) rather than only on something that has no ideactxexists. missing or malformed Authorization headeron a request you’re sure has a token — the header value must be exactlyBearer <token>, one space, case-sensitive onBearer. A common mistake: sending just the raw token with noBearerprefix at all.- A panic in one handler seems to take the whole server down anyway — confirm
recoverMiddlewareis actually the outermost layer (recoverMiddleware(log, loggingMiddleware(log, mux)), not the other way around) — a panic insideloggingMiddlewareitself, outside therecover, would still escape.
Do’s and don’ts
- Do validate and clamp query parameters (
limit,offset) rather than trusting them —handleListOrdersnever 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’sloginRequestnever 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.