Skip to content
gohacks.dev
APIs

Design and Build a JSON API in Go That Stays Maintainable

Build a JSON API in Go from a written contract: standard routing, one error format, strict decoding, validation, cursor paging, PATCH, auth and tests.

39 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response, with the JSON logo.

An API is a promise to people you will never meet, kept for years by people who have not been hired yet. The code that satisfies the promise today is the smaller part of the job. The larger part is choosing a shape that survives the next feature, the next developer and the next client written by someone who read only the response bodies. Most JSON APIs in Go that turn into maintenance burdens do so for dull reasons: each handler invented its own error format, validation was scattered, paging was bolted on after the table grew, and nothing was written down.

Tested with Go 1.25.6 and modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)

This is the cornerstone of the APIs series. It builds a small tasks API from nothing, in the order I would make the decisions in a real project, and every piece of it ran: the program that exercises it, and the tests that pin its rules, are shown with their output. Each section links to the article that goes deeper on one topic, so you can read this one end to end and then pick the parts that matter to you. The foundations come from the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6 with modernc.org/sqlite v1.50.0 as a pure Go database driver, so there is nothing to install.

Start with the contract

Before any code, write down what the API does. Not a diagram, a list. For a tasks service:

Method and pathMeaningSuccessTypical failures
GET /v1/tasksA page of tasks, optionally filtered by status200400 bad filter, cursor or limit
POST /v1/tasksCreate a task201 and Location400 malformed, 415, 422 invalid
GET /v1/tasks/{id}One task200400 bad id, 404
PATCH /v1/tasks/{id}Change some fields200400, 404, 422
DELETE /v1/tasks/{id}Remove a task204400, 404
GET /healthzLiveness, no authentication200none

Three rules sit under that table, and they are what REST API design rules develops in full. Resources are nouns and methods are verbs: the path names a thing (/v1/tasks/7) and the method says what to do with it, so there is no /v1/createTask. Status codes are chosen from a short fixed list and mean what the specification says, as in status codes for APIs. And there is exactly one error shape: every failure from every endpoint, whatever its cause, is the same kind of document, so a client writes one error handler.

Two more choices belong in the contract and are costly to change later. The URL carries a version (/v1/), so a breaking change has somewhere to go (API versioning). And everything is JSON in both directions, with dates as RFC 3339 strings and identifiers as numbers, decided once. How responses are wrapped (a bare object for one task, a data envelope for lists) is the subject of structuring JSON responses; I use the minimal convention here: a single resource is the object itself, and a list is {"data": [...], "next_cursor": "..."} so that paging metadata has somewhere to live.

How the code is laid out

The demo is one package, split into files by job: store.go for the database, problems.go for the error format, middleware.go for cross-cutting behavior, api.go for the handlers and routes, and the tests. That is the right size for a service of this scope. When it grows, the first split is the one in route organization: features get their own packages that register their routes, and the dependencies they need are passed in, as in handler dependency injection. The rule that keeps the layout healthy at any size is the direction of dependencies. Handlers know about HTTP and call a store; the store knows about SQL and knows nothing about HTTP; neither imports the other's concerns. A handler that builds SQL, or a store function that returns a status code, is the start of a tangle.

I use the standard library's ServeMux for routing. Since Go 1.22 it matches methods and path parameters (GET /v1/tasks/{id}), which is all a JSON API needs; the comparison with routers and frameworks is in the standard library or a framework, and the same job done twice in do you need a framework. Handlers are plain http.HandlerFunc methods, so every tool in the ecosystem works with them.

The store

store.goGo
package main

import (
	"context"
	"database/sql"
	"errors"
	"time"

	_ "modernc.org/sqlite"
)

var ErrNotFound = errors.New("task not found")

type Task struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	Status    string    `json:"status"`
	Notes     string    `json:"notes,omitempty"`
	DueDate   *string   `json:"due_date,omitempty"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

// TaskPatch holds only the fields a client sent; nil means "leave it alone".
type TaskPatch struct {
	Title   *string `json:"title"`
	Status  *string `json:"status"`
	Notes   *string `json:"notes"`
	DueDate *string `json:"due_date"`
}

type Store struct{ db *sql.DB }

func OpenStore(dsn string) (*Store, error) {
	db, err := sql.Open("sqlite", dsn)
	if err != nil {
		return nil, err
	}
	db.SetMaxOpenConns(1)
	_, err = db.Exec(`CREATE TABLE IF NOT EXISTS tasks (
		id         INTEGER PRIMARY KEY AUTOINCREMENT,
		title      TEXT NOT NULL,
		status     TEXT NOT NULL DEFAULT 'todo',
		notes      TEXT NOT NULL DEFAULT '',
		due_date   TEXT,
		created_at TEXT NOT NULL,
		updated_at TEXT NOT NULL
	)`)
	if err != nil {
		db.Close()
		return nil, err
	}
	return &Store{db: db}, nil
}

func (s *Store) Close() error { return s.db.Close() }

const cols = `id, title, status, notes, due_date, created_at, updated_at`

type scanner interface{ Scan(dest ...any) error }

func scan(r scanner) (Task, error) {
	var t Task
	var due sql.NullString
	var created, updated string
	if err := r.Scan(&t.ID, &t.Title, &t.Status, &t.Notes, &due, &created, &updated); err != nil {
		return Task{}, err
	}
	if due.Valid {
		t.DueDate = &due.String
	}
	t.CreatedAt, _ = time.Parse(time.RFC3339, created)
	t.UpdatedAt, _ = time.Parse(time.RFC3339, updated)
	return t, nil
}

func (s *Store) Create(ctx context.Context, t Task, now time.Time) (Task, error) {
	ts := now.UTC().Format(time.RFC3339)
	row := s.db.QueryRowContext(ctx,
		`INSERT INTO tasks (title, status, notes, due_date, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING `+cols,
		t.Title, t.Status, t.Notes, t.DueDate, ts, ts)
	return scan(row)
}

func (s *Store) Get(ctx context.Context, id int64) (Task, error) {
	t, err := scan(s.db.QueryRowContext(ctx, `SELECT `+cols+` FROM tasks WHERE id = ?`, id))
	if errors.Is(err, sql.ErrNoRows) {
		return Task{}, ErrNotFound
	}
	return t, err
}

// List returns up to limit tasks with id greater than after, oldest first, optionally filtered by status.
func (s *Store) List(ctx context.Context, status string, after int64, limit int) ([]Task, error) {
	rows, err := s.db.QueryContext(ctx,
		`SELECT `+cols+` FROM tasks WHERE id > ? AND (? = '' OR status = ?) ORDER BY id LIMIT ?`,
		after, status, status, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Task
	for rows.Next() {
		t, err := scan(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, t)
	}
	return out, rows.Err()
}

// Update applies a patch in one statement: COALESCE keeps the old value wherever the client sent nothing.
func (s *Store) Update(ctx context.Context, id int64, p TaskPatch, now time.Time) (Task, error) {
	t, err := scan(s.db.QueryRowContext(ctx,
		`UPDATE tasks SET title = COALESCE(?, title), status = COALESCE(?, status), notes = COALESCE(?, notes),
		 due_date = COALESCE(?, due_date), updated_at = ? WHERE id = ? RETURNING `+cols,
		p.Title, p.Status, p.Notes, p.DueDate, now.UTC().Format(time.RFC3339), id))
	if errors.Is(err, sql.ErrNoRows) {
		return Task{}, ErrNotFound
	}
	return t, err
}

func (s *Store) Delete(ctx context.Context, id int64) error {
	res, err := s.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return ErrNotFound
	}
	return nil
}

The store is a thin layer of SQL behind a few methods with plain Go types, and every method takes a context.Context, so a cancelled request cancels its query. Four details are deliberate.

Create and Update use RETURNING, which hands back the row as the database stored it, with generated ids and timestamps, in the same statement. There is no insert followed by a select, so there is no window for another writer to slip into, and no second round trip. The setup follows the URL shortener.

Update is a patch in a single statement. COALESCE(?, title) means "use the new value if one was bound, otherwise keep the old one", and TaskPatch carries pointers, so a field the client did not send is nil, binds as SQL NULL, and leaves the column alone. It is compact and atomic. Its limit is that it cannot set a nullable column back to NULL, because nil already means "not sent". If clearing a due date must be possible, the patch type needs to tell "absent" from "explicit null", which telling null, missing and zero apart covers; for this API I accept the limit and document it.

List takes the filter, a cursor expressed as "ids greater than this", and a limit, and orders by id. Ordering by the primary key is what makes the cursor stable: new tasks get larger ids, so they appear at the end and never shift the pages already seen.

ErrNotFound is a sentinel error, translated at the edge into a 404. The store never knows about status codes.

One error format for everything

problems.goGo
package main

import (
	"encoding/json"
	"net/http"
)

// Problem is an RFC 9457 problem details document, the one error shape every endpoint returns.
type Problem struct {
	Type     string            `json:"type"`
	Title    string            `json:"title"`
	Status   int               `json:"status"`
	Detail   string            `json:"detail,omitempty"`
	Instance string            `json:"instance,omitempty"`
	Errors   map[string]string `json:"errors,omitempty"` // field name to message, for validation failures
}

const problemBase = "https://api.example.test/problems/"

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

func writeProblem(w http.ResponseWriter, r *http.Request, status int, slug, title, detail string, fields map[string]string) {
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(Problem{
		Type: problemBase + slug, Title: title, Status: status, Detail: detail,
		Instance: r.Header.Get("X-Request-Id"), Errors: fields,
	})
}

Every failure goes through writeProblem, which writes an RFC 9457 problem details document (the current version of RFC 7807): a type URI naming the kind of problem, a human title, the numeric status, an optional detail, an instance and, here, an errors map from field name to message for validation failures. The content type is application/problem+json. The full treatment, including how to design the type URIs and what clients should key on, is in error responses with problem details.

The points that make this useful in practice:

  • Clients branch on type and status, never on detail. The detail string is for people and may change wording; the slug in the type URI is the stable identifier. Document the list of types.
  • The request id goes in instance. A client that reports a problem can quote it, and you find the log line in one search. It is the single most useful field in a support conversation.
  • Validation is a 422 with every field listed. A form that has to be submitted five times to discover five mistakes is a bad API. errors carries all of them at once.
  • Internal errors say nothing. A 500 returns "Internal error" and the request id, while the cause goes to the log. Database messages, file paths and stack traces help attackers and nobody else.

Here is the first honest finding from running this. My first version used the problem format in every handler and still returned plain text for a PUT to a route that only accepts GET, PATCH and DELETE: the 405 Method Not Allowed and the 404 for unknown paths are written by the mux itself, in text/plain, before any handler runs. A client that expects JSON from this API would choke on them. The fix is the Problemize wrapper in the middleware file below: it watches for a 404 or 405 whose content type is text/plain, which a handler of ours never produces, swallows the mux's body and writes a problem document instead, keeping the Allow header the mux set. A test now pins that every error is a problem document, the mux's included.

Middleware: what runs around every request

middleware.goGo
package main

import (
	"context"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"sync/atomic"
	"time"
)

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, m ...Middleware) http.Handler {
	for i := len(m) - 1; i >= 0; i-- {
		h = m[i](h)
	}
	return h
}

type ctxKey string

const (
	clientKey ctxKey = "client"
	infoKey   ctxKey = "info"
)

// reqInfo is a mutable note that outer middleware plants in the context and inner middleware fills in.
// Without it the access log, which runs outside Auth, could never learn who the client was.
type reqInfo struct{ Client string }

// RequestID keeps a caller's X-Request-Id or makes one, so a single id follows the request through logs and errors.
func RequestID() Middleware {
	var n atomic.Int64
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			id := r.Header.Get("X-Request-Id")
			if id == "" || len(id) > 64 {
				id = fmt.Sprintf("req-%06d", n.Add(1))
			}
			r.Header.Set("X-Request-Id", id)
			w.Header().Set("X-Request-Id", id)
			next.ServeHTTP(w, r)
		})
	}
}

func Recover(log *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if v := recover(); v != nil {
					log.Error("panic", "request_id", r.Header.Get("X-Request-Id"), "panic", fmt.Sprint(v))
					writeProblem(w, r, http.StatusInternalServerError, "internal", "Internal error", "", nil)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

type statusWriter struct {
	http.ResponseWriter
	status int
}

func (s *statusWriter) WriteHeader(code int)        { s.status = code; s.ResponseWriter.WriteHeader(code) }
func (s *statusWriter) Unwrap() http.ResponseWriter { return s.ResponseWriter }

func AccessLog(log *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
			info := &reqInfo{}
			start := time.Now()
			next.ServeHTTP(sw, r.WithContext(context.WithValue(r.Context(), infoKey, info)))
			log.Info("request", "method", r.Method, "path", r.URL.Path, "status", sw.status, "request_id", r.Header.Get("X-Request-Id"), "client", info.Client, "ms", time.Since(start).Milliseconds())
		})
	}
}

// Auth checks a bearer API key against the known clients and stores the client's name in the context.
func Auth(keys map[string]string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
			client, known := keys[token]
			if !ok || !known {
				w.Header().Set("WWW-Authenticate", `Bearer realm="tasks"`)
				writeProblem(w, r, http.StatusUnauthorized, "unauthorized", "Authentication required", "Send a valid API key as a bearer token.", nil)
				return
			}
			if info, ok := r.Context().Value(infoKey).(*reqInfo); ok {
				info.Client = client
			}
			next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientKey, client)))
		})
	}
}

// problemWriter replaces the plain text 404 and 405 answers that the mux writes itself with problem documents,
// so a client never has to handle two error formats.
type problemWriter struct {
	http.ResponseWriter
	r         *http.Request
	swallowed bool
}

func (p *problemWriter) WriteHeader(code int) {
	if (code == http.StatusNotFound || code == http.StatusMethodNotAllowed) && strings.HasPrefix(p.Header().Get("Content-Type"), "text/plain") {
		p.swallowed = true
		slug, title := "not-found", "Not found"
		if code == http.StatusMethodNotAllowed {
			slug, title = "method-not-allowed", "Method not allowed"
		}
		writeProblem(p.ResponseWriter, p.r, code, slug, title, "", nil)
		return
	}
	p.ResponseWriter.WriteHeader(code)
}

func (p *problemWriter) Write(b []byte) (int, error) {
	if p.swallowed {
		return len(b), nil
	}
	return p.ResponseWriter.Write(b)
}

func (p *problemWriter) Unwrap() http.ResponseWriter { return p.ResponseWriter }

func Problemize(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(&problemWriter{ResponseWriter: w, r: r}, r)
	})
}

// JSONOnly refuses write requests whose body is not declared as JSON.
func JSONOnly(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method == http.MethodPost || r.Method == http.MethodPatch || r.Method == http.MethodPut {
			if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(strings.ToLower(ct), "application/json") {
				writeProblem(w, r, http.StatusUnsupportedMediaType, "unsupported-media-type", "Unsupported media type", "Send Content-Type: application/json.", nil)
				return
			}
		}
		next.ServeHTTP(w, r)
	})
}

Cross-cutting behavior belongs in middleware, written as func(http.Handler) http.Handler and composed with Chain, which applies them so the first in the list is the outermost, exactly as in middleware basics. The chain here, outside in, is RequestID, AccessLog, Recover, Problemize, and then the router, with Auth and JSONOnly applied only to the /v1/ subtree.

RequestID, the outermost layer, adopts the caller's X-Request-Id (if it is a sane length) or generates one, puts it on the request and on the response, and everything downstream can use it. Because it is outermost, every log line and every problem document carries it.

Recover turns a panic anywhere below into a logged event and a 500 problem document, so one buggy handler does not take down the process. It sits inside the access log on purpose: the panic is converted to a 500 before control returns to the logger, so the request is logged with its real status. Swap the two and the panic unwinds through the logger, which never reaches its logging statement, and the failing request vanishes from the access log; the middleware testing article demonstrates it.

AccessLog records method, path, status, request id, client and duration through log/slog. Here is the second finding. My first version read the client name from the request context after the handler returned, and the log showed client="" for every request. The reason is structural: Auth runs inside the logger and stores the client in a derived request context, which the outer logger cannot see. The fix is the reqInfo note: the logger plants a pointer to a small mutable struct in the context before calling inward, Auth fills in the client through that pointer, and the logger reads it afterwards. The final line of the program's output shows client=mobile-app. Anything an inner layer learns that an outer layer must report (the authenticated user, the matched route pattern, a tenant id) needs this shape.

Auth checks a bearer API key against a map of known clients and answers 401 with a WWW-Authenticate header otherwise. Static keys in a map are the simplest thing that works for a small internal API. For real use, store only hashes of keys, compare in constant time, and give each key a name and scopes, as in API keys; for user-facing APIs the choices widen to sessions, JWTs and OAuth, laid out in authentication for Go APIs and bearer tokens and refresh flows. Notice that /healthz is outside the authenticated subtree, because orchestrators probe it without credentials.

JSONOnly refuses POST, PATCH and PUT requests that are not declared as application/json with a 415. The order has a visible consequence: it runs before routing, so a PUT with no body gets a 415 even on a route that does not accept PUT; the 405 only appears when the content type is right. That is a defensible order, but it is a decision, and the test that checks the 405 has to send a JSON body to reach it.

The handlers

api.goGo
package main

import (
	"encoding/base64"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"
)

var validStatus = map[string]bool{"todo": true, "doing": true, "done": true}

type API struct {
	store *Store
	now   func() time.Time
}

// New wires the routes and the middleware. Everything under /v1/ needs a key; /healthz does not.
func New(store *Store, log *slog.Logger, keys map[string]string) http.Handler {
	a := &API{store: store, now: time.Now}
	v1 := http.NewServeMux()
	v1.HandleFunc("GET /v1/tasks", a.list)
	v1.HandleFunc("POST /v1/tasks", a.create)
	v1.HandleFunc("GET /v1/tasks/{id}", a.get)
	v1.HandleFunc("PATCH /v1/tasks/{id}", a.patch)
	v1.HandleFunc("DELETE /v1/tasks/{id}", a.remove)

	root := http.NewServeMux()
	root.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"status": "ok"}) })
	root.Handle("/v1/", Chain(v1, Auth(keys), JSONOnly))
	return Chain(root, RequestID(), AccessLog(log), Recover(log), Problemize)
}

// decode reads exactly one JSON object, with a size cap, rejecting unknown fields and trailing data.
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
	dec.DisallowUnknownFields()
	err := dec.Decode(dst)
	if err == nil {
		if _, extra := dec.Token(); !errors.Is(extra, io.EOF) {
			err = errors.New("the body must contain a single JSON object")
		}
	}
	if err != nil {
		var tooBig *http.MaxBytesError
		if errors.As(err, &tooBig) {
			writeProblem(w, r, http.StatusRequestEntityTooLarge, "too-large", "Body too large", "The request body is limited to 1 MiB.", nil)
		} else {
			writeProblem(w, r, http.StatusBadRequest, "malformed-json", "Malformed request body", err.Error(), nil)
		}
		return false
	}
	return true
}

func validateTitle(s string) string {
	if n := utf8.RuneCountInString(strings.TrimSpace(s)); n < 1 || n > 120 {
		return "must be 1 to 120 characters"
	}
	return ""
}

func validateDue(s string) string {
	if _, err := time.Parse("2006-01-02", s); err != nil {
		return "must be a date like 2026-03-31"
	}
	return ""
}

func (a *API) create(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Title   string  `json:"title"`
		Status  string  `json:"status"`
		Notes   string  `json:"notes"`
		DueDate *string `json:"due_date"`
	}
	if !decode(w, r, &in) {
		return
	}
	if in.Status == "" {
		in.Status = "todo"
	}
	fields := map[string]string{}
	if m := validateTitle(in.Title); m != "" {
		fields["title"] = m
	}
	if !validStatus[in.Status] {
		fields["status"] = "must be one of todo, doing, done"
	}
	if in.DueDate != nil {
		if m := validateDue(*in.DueDate); m != "" {
			fields["due_date"] = m
		}
	}
	if len(fields) > 0 {
		writeProblem(w, r, http.StatusUnprocessableEntity, "validation", "Validation failed", "One or more fields are invalid.", fields)
		return
	}
	t, err := a.store.Create(r.Context(), Task{Title: strings.TrimSpace(in.Title), Status: in.Status, Notes: in.Notes, DueDate: in.DueDate}, a.now())
	if err != nil {
		writeProblem(w, r, http.StatusInternalServerError, "internal", "Internal error", "", nil)
		return
	}
	w.Header().Set("Location", "/v1/tasks/"+strconv.FormatInt(t.ID, 10))
	writeJSON(w, http.StatusCreated, t)
}

func taskID(w http.ResponseWriter, r *http.Request) (int64, bool) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil || id < 1 {
		writeProblem(w, r, http.StatusBadRequest, "bad-id", "Invalid task id", "The id must be a positive integer.", nil)
		return 0, false
	}
	return id, true
}

func (a *API) get(w http.ResponseWriter, r *http.Request) {
	id, ok := taskID(w, r)
	if !ok {
		return
	}
	t, err := a.store.Get(r.Context(), id)
	if errors.Is(err, ErrNotFound) {
		writeProblem(w, r, http.StatusNotFound, "not-found", "Task not found", "", nil)
		return
	}
	if err != nil {
		writeProblem(w, r, http.StatusInternalServerError, "internal", "Internal error", "", nil)
		return
	}
	writeJSON(w, http.StatusOK, t)
}

func (a *API) patch(w http.ResponseWriter, r *http.Request) {
	id, ok := taskID(w, r)
	if !ok {
		return
	}
	var p TaskPatch
	if !decode(w, r, &p) {
		return
	}
	fields := map[string]string{}
	if p.Title != nil {
		if m := validateTitle(*p.Title); m != "" {
			fields["title"] = m
		}
	}
	if p.Status != nil && !validStatus[*p.Status] {
		fields["status"] = "must be one of todo, doing, done"
	}
	if p.DueDate != nil {
		if m := validateDue(*p.DueDate); m != "" {
			fields["due_date"] = m
		}
	}
	if len(fields) > 0 {
		writeProblem(w, r, http.StatusUnprocessableEntity, "validation", "Validation failed", "One or more fields are invalid.", fields)
		return
	}
	t, err := a.store.Update(r.Context(), id, p, a.now())
	if errors.Is(err, ErrNotFound) {
		writeProblem(w, r, http.StatusNotFound, "not-found", "Task not found", "", nil)
		return
	}
	if err != nil {
		writeProblem(w, r, http.StatusInternalServerError, "internal", "Internal error", "", nil)
		return
	}
	writeJSON(w, http.StatusOK, t)
}

func (a *API) remove(w http.ResponseWriter, r *http.Request) {
	id, ok := taskID(w, r)
	if !ok {
		return
	}
	switch err := a.store.Delete(r.Context(), id); {
	case errors.Is(err, ErrNotFound):
		writeProblem(w, r, http.StatusNotFound, "not-found", "Task not found", "", nil)
	case err != nil:
		writeProblem(w, r, http.StatusInternalServerError, "internal", "Internal error", "", nil)
	default:
		w.WriteHeader(http.StatusNoContent)
	}
}

// Cursors are opaque to clients: today the id of the last item, encoded so nobody builds one by hand.
func encodeCursor(id int64) string {
	return base64.RawURLEncoding.EncodeToString([]byte("t:" + strconv.FormatInt(id, 10)))
}

func decodeCursor(s string) (int64, bool) {
	raw, err := base64.RawURLEncoding.DecodeString(s)
	if err != nil || !strings.HasPrefix(string(raw), "t:") {
		return 0, false
	}
	id, err := strconv.ParseInt(strings.TrimPrefix(string(raw), "t:"), 10, 64)
	return id, err == nil && id >= 0
}

type listResponse struct {
	Data       []Task `json:"data"`
	NextCursor string `json:"next_cursor,omitempty"`
}

func (a *API) list(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	status := q.Get("status")
	if status != "" && !validStatus[status] {
		writeProblem(w, r, http.StatusBadRequest, "bad-filter", "Invalid filter", "status must be one of todo, doing, done.", nil)
		return
	}
	limit := 20
	if s := q.Get("limit"); s != "" {
		n, err := strconv.Atoi(s)
		if err != nil || n < 1 || n > 100 {
			writeProblem(w, r, http.StatusBadRequest, "bad-limit", "Invalid limit", "limit must be between 1 and 100.", nil)
			return
		}
		limit = n
	}
	var after int64
	if c := q.Get("cursor"); c != "" {
		var ok bool
		if after, ok = decodeCursor(c); !ok {
			writeProblem(w, r, http.StatusBadRequest, "bad-cursor", "Invalid cursor", "Use the next_cursor value from a previous response.", nil)
			return
		}
	}
	tasks, err := a.store.List(r.Context(), status, after, limit+1) // one extra row tells us whether there is a next page
	if err != nil {
		writeProblem(w, r, http.StatusInternalServerError, "internal", "Internal error", "", nil)
		return
	}
	resp := listResponse{Data: tasks}
	if len(tasks) > limit {
		resp.Data = tasks[:limit]
		resp.NextCursor = encodeCursor(tasks[limit-1].ID)
	}
	if resp.Data == nil {
		resp.Data = []Task{} // an empty list is [], never null
	}
	writeJSON(w, http.StatusOK, resp)
}

Decode strictly

The decode helper is the front door for every write, and it is stricter than json.NewDecoder(r.Body).Decode(&v) in three ways. The body is wrapped in http.MaxBytesReader with a 1 MiB cap, so a hostile client cannot make the server read gigabytes (limiting request body size); exceeding it is a 413, recognised with errors.As against *http.MaxBytesError. The decoder calls DisallowUnknownFields, so a typo in a field name ("duedate") is a 400 that names the field instead of a silently ignored value (rejecting unknown fields). And after decoding it asks for another token: if anything other than end of input follows, the body held two JSON values, which is a 400 as well, because {"title":"a"}{"title":"b"} should not be half-accepted. The malformed-JSON error text goes into detail, which tells a developer exactly what went wrong at the cost of exposing Go's wording; if you would rather not, replace it with a fixed sentence.

Validate everything, once

Validation is a separate step from decoding, and it collects errors in a map rather than returning at the first. Titles must be 1 to 120 characters measured in runes (so an accented name is not longer than a plain one), status must be one of three values, and a due date must parse as 2006-01-02. A request breaking all three gets one 422 listing all three. The rules live in small functions (validateTitle, validateDue) that create and patch share, so the two cannot drift. For larger models, the trade-offs between hand-written checks and tag-based libraries are in request validation and the validator package. Which status code to use is a perennial argument; I use 400 for requests that cannot be understood (bad JSON, bad id, bad query parameter) and 422 for well-formed requests that break a rule, because clients can then treat the first as a bug on their side and the second as something to show a user.

Paging with cursors

list returns up to limit tasks (default 20, maximum 100) and a next_cursor when more remain. Two techniques make it work. The handler asks the store for limit+1 rows: if it gets more than limit, it knows there is a next page without a separate COUNT query, and it trims the extra row. And the cursor is an opaque string, here the base64 of the last id, which a client must treat as a black box. Today it is an id; tomorrow it can carry a sort key and a filter hash, and clients do not break, because they never parsed it. An invalid cursor is a 400. The alternatives (offset paging, which drifts when rows are inserted, and keyset paging with composite sort keys) are compared in pagination in Go APIs, and filtering and sorting safely, with allowlists for column names, is in filtering and sorting. The status filter here is validated against the same allowlist as writes, so ?status=banana is a 400 and not an empty list that hides a client bug.

Also notice if resp.Data == nil { resp.Data = []Task{} }. A Go nil slice marshals to null, and a client that does response.data.length on null crashes. An empty collection is [], always, and a test checks it.

PATCH changes only what it is sent

patch decodes into TaskPatch, whose fields are pointers, validates only the fields that are present, and calls the single-statement update. The test below sends {"status":"done"} for a task with notes and a due date and asserts that the title, notes and due date are untouched. This is the semantics people expect from PATCH and not what a naive struct decode gives: with plain value fields, an omitted notes would decode to the empty string and overwrite the stored notes. The difference between PATCH and PUT (replace the whole resource) and the formal merge-patch rules of RFC 7396 are covered in partial updates with PATCH, and the PUT-style replacement fits better for small resources that clients always send whole.

Create, read, delete

create validates, inserts, and answers 201 Created with a Location header pointing at the new resource and the stored task in the body, so the client learns the id and the server-assigned timestamps in the same response. get and delete parse the id (a positive integer, or 400), call the store and map ErrNotFound to a 404 problem; delete answers 204 No Content with an empty body. A second delete of the same id is a 404, not a 204: repeat deletes are idempotent in effect, and many APIs return 204 for them too, which is defensible. Pick one and document it; I prefer the 404 because it tells a client its picture of the world was out of date. If clients retry creates after timeouts, the dangerous method is POST, and idempotency keys are the cure.

Running the whole thing

The program starts the API on a test server with an in-memory database and one API key, then drives it through the contract: it creates five tasks, pages through them two at a time, filters, patches, deletes, and then sends a series of bad requests. Each line is the call, the status and the start of the body.

main.goGo
package main

import (
	"bytes"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"regexp"
	"strings"
)

func main() {
	store, err := OpenStore("file::memory:")
	if err != nil {
		panic(err)
	}
	defer store.Close()
	var logs bytes.Buffer
	log := slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
		if a.Key == "time" || a.Key == "ms" {
			return slog.Attr{}
		}
		return a
	}}))
	srv := httptest.NewServer(New(store, log, map[string]string{"key-abc": "mobile-app"}))
	defer srv.Close()

	call := func(label, method, path, body string, hdr ...string) (*http.Response, string) {
		req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
		req.Header.Set("Authorization", "Bearer key-abc")
		if body != "" {
			req.Header.Set("Content-Type", "application/json")
		}
		for i := 0; i+1 < len(hdr); i += 2 {
			if hdr[i+1] == "" {
				req.Header.Del(hdr[i])
			} else {
				req.Header.Set(hdr[i], hdr[i+1])
			}
		}
		res, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}
		defer res.Body.Close()
		b, _ := io.ReadAll(res.Body)
		out := strings.TrimSpace(string(b))
		if len(out) > 150 {
			out = out[:150] + "..."
		}
		fmt.Printf("%-30s %d %s\n", label, res.StatusCode, out)
		return res, string(b)
	}
	cursor := regexp.MustCompile(`"next_cursor":"([^"]+)"`)

	call("create a task", "POST", "/v1/tasks", `{"title":"Write the report","due_date":"2026-03-31"}`)
	call("create more", "POST", "/v1/tasks", `{"title":"Review the PR","status":"doing"}`)
	for i := 3; i <= 5; i++ {
		call(fmt.Sprintf("create task %d", i), "POST", "/v1/tasks", fmt.Sprintf(`{"title":"Task %d"}`, i))
	}
	fmt.Println()
	_, page1 := call("list, limit 2", "GET", "/v1/tasks?limit=2", "")
	next := cursor.FindStringSubmatch(page1)[1]
	_, page2 := call("list, second page", "GET", "/v1/tasks?limit=2&cursor="+next, "")
	next = cursor.FindStringSubmatch(page2)[1]
	call("list, third page", "GET", "/v1/tasks?limit=2&cursor="+next, "")
	call("filter by status", "GET", "/v1/tasks?status=doing", "")
	fmt.Println()
	call("get one", "GET", "/v1/tasks/1", "")
	call("patch the status only", "PATCH", "/v1/tasks/1", `{"status":"done"}`)
	call("delete", "DELETE", "/v1/tasks/5", "")
	call("delete again", "DELETE", "/v1/tasks/5", "")
	fmt.Println()
	call("validation errors", "POST", "/v1/tasks", `{"title":"   ","status":"someday","due_date":"soon"}`)
	call("unknown field", "POST", "/v1/tasks", `{"title":"x","priority":1}`)
	call("malformed JSON", "POST", "/v1/tasks", `{"title":`)
	call("two JSON values", "POST", "/v1/tasks", `{"title":"a"}{"title":"b"}`)
	call("wrong content type", "POST", "/v1/tasks", `title=x`, "Content-Type", "text/plain")
	call("body over 1 MiB", "POST", "/v1/tasks", `{"title":"`+strings.Repeat("x", 2<<20)+`"}`)
	call("bad id", "GET", "/v1/tasks/abc", "")
	call("bad cursor", "GET", "/v1/tasks?cursor=hello", "")
	call("bad limit", "GET", "/v1/tasks?limit=500", "")
	call("wrong method", "PUT", "/v1/tasks/1", `{}`)
	call("unknown path", "GET", "/v1/projects", "")
	fmt.Println()
	call("no credentials", "GET", "/v1/tasks", "", "Authorization", "")
	call("wrong key", "GET", "/v1/tasks", "", "Authorization", "Bearer nope")
	call("health check, no key", "GET", "/healthz", "", "Authorization", "")
	res, _ := call("caller's request id", "GET", "/v1/tasks/1", "", "X-Request-Id", "trace-42")
	fmt.Println("response header X-Request-Id:", res.Header.Get("X-Request-Id"))
	fmt.Println()
	fmt.Println("access log, last three lines:")
	lines := strings.Split(strings.TrimSpace(logs.String()), "\n")
	for _, l := range lines[len(lines)-3:] {
		fmt.Println("  " + l)
	}
}
Shell
go run .
Text
create a task                  201 {"id":1,"title":"Write the report","status":"todo","due_date":"2026-03-31","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}
create more                    201 {"id":2,"title":"Review the PR","status":"doing","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}
create task 3                  201 {"id":3,"title":"Task 3","status":"todo","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}
create task 4                  201 {"id":4,"title":"Task 4","status":"todo","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}
create task 5                  201 {"id":5,"title":"Task 5","status":"todo","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}

list, limit 2                  200 {"data":[{"id":1,"title":"Write the report","status":"todo","due_date":"2026-03-31","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13...
list, second page              200 {"data":[{"id":3,"title":"Task 3","status":"todo","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"},{"id":4,"title":"Task 4","s...
list, third page               200 {"data":[{"id":5,"title":"Task 5","status":"todo","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}]}
filter by status               200 {"data":[{"id":2,"title":"Review the PR","status":"doing","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}]}

get one                        200 {"id":1,"title":"Write the report","status":"todo","due_date":"2026-03-31","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}
patch the status only          200 {"id":1,"title":"Write the report","status":"done","due_date":"2026-03-31","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}
delete                         204 
delete again                   404 {"type":"https://api.example.test/problems/not-found","title":"Task not found","status":404,"instance":"req-000013"}

validation errors              422 {"type":"https://api.example.test/problems/validation","title":"Validation failed","status":422,"detail":"One or more fields are invalid.","instance":...
unknown field                  400 {"type":"https://api.example.test/problems/malformed-json","title":"Malformed request body","status":400,"detail":"json: unknown field \"priority\"","...
malformed JSON                 400 {"type":"https://api.example.test/problems/malformed-json","title":"Malformed request body","status":400,"detail":"unexpected EOF","instance":"req-000...
two JSON values                400 {"type":"https://api.example.test/problems/malformed-json","title":"Malformed request body","status":400,"detail":"the body must contain a single JSON...
wrong content type             415 {"type":"https://api.example.test/problems/unsupported-media-type","title":"Unsupported media type","status":415,"detail":"Send Content-Type: applicat...
body over 1 MiB                413 {"type":"https://api.example.test/problems/too-large","title":"Body too large","status":413,"detail":"The request body is limited to 1 MiB.","instance...
bad id                         400 {"type":"https://api.example.test/problems/bad-id","title":"Invalid task id","status":400,"detail":"The id must be a positive integer.","instance":"re...
bad cursor                     400 {"type":"https://api.example.test/problems/bad-cursor","title":"Invalid cursor","status":400,"detail":"Use the next_cursor value from a previous respo...
bad limit                      400 {"type":"https://api.example.test/problems/bad-limit","title":"Invalid limit","status":400,"detail":"limit must be between 1 and 100.","instance":"req...
wrong method                   405 {"type":"https://api.example.test/problems/method-not-allowed","title":"Method not allowed","status":405,"instance":"req-000023"}
unknown path                   404 {"type":"https://api.example.test/problems/not-found","title":"Not found","status":404,"instance":"req-000024"}

no credentials                 401 {"type":"https://api.example.test/problems/unauthorized","title":"Authentication required","status":401,"detail":"Send a valid API key as a bearer tok...
wrong key                      401 {"type":"https://api.example.test/problems/unauthorized","title":"Authentication required","status":401,"detail":"Send a valid API key as a bearer tok...
health check, no key           200 {"status":"ok"}
caller's request id            200 {"id":1,"title":"Write the report","status":"done","due_date":"2026-03-31","created_at":"2026-10-26T22:13:58Z","updated_at":"2026-10-26T22:13:58Z"}
response header X-Request-Id: trace-42

access log, last three lines:
  level=INFO msg=request method=GET path=/v1/tasks status=401 request_id=req-000026 client=""
  level=INFO msg=request method=GET path=/healthz status=200 request_id=req-000027 client=""
  level=INFO msg=request method=GET path=/v1/tasks/1 status=200 request_id=trace-42 client=mobile-app

Read the output as a checklist against the contract.

The first block creates tasks and each returns 201 with the stored object. Notice what the server added: id, created_at and updated_at, and the default status of todo, while the optional due_date appears only where the client supplied one (it uses omitempty and a pointer).

The listing block pages with limit=2. The first response has two tasks and a cursor; the second page, requested with that cursor, has tasks 3 and 4; the third has only task 5 and no next_cursor, which is how a client knows it has reached the end. Filtering by status=doing returns only the task in that state. The patch block changes only the status of task 1, and its other fields are intact in the response. The delete returns 204, and deleting again returns a 404 problem document with the request id in instance.

The errors block is the part that earns trust. Three simultaneous problems in one request come back as a single 422 with a field map. A field the API does not know, malformed JSON, two JSON values in one body, a wrong content type, an oversized body, a non-numeric id, a nonsense cursor and an out-of-range limit each get their own 4xx with its own problem type, and the body over 1 MiB is a 413 that was refused before the server decoded it. The wrong-method request and the unknown path return problem documents too, courtesy of the wrapper described earlier.

The last block checks identity. No credentials and a wrong key are both 401. The health check works without a key. And a caller-supplied X-Request-Id of trace-42 is echoed back in the response header and appears in the access log, where the final line also shows client=mobile-app, the value Auth shared with the logger.

Tests pin the rules

The program shows behavior once; tests keep it from changing. They use httptest against the assembled handler, the real chain with the real router, because a test of a handler function that skips the middleware would miss the bugs that live in the seams: the 415 that precedes the 405, the context value the logger cannot see, the plain-text 405.

api_test.goGo
package main

import (
	"encoding/json"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func newAPI(t *testing.T) http.Handler {
	t.Helper()
	store, err := OpenStore("file::memory:")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { store.Close() })
	return New(store, slog.New(slog.NewTextHandler(io.Discard, nil)), map[string]string{"k": "test"})
}

func do(h http.Handler, method, path, body string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, path, strings.NewReader(body))
	req.Header.Set("Authorization", "Bearer k")
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func TestValidationReportsEveryFieldAtOnce(t *testing.T) {
	h := newAPI(t)
	rec := do(h, "POST", "/v1/tasks", `{"title":"","status":"nope","due_date":"x"}`)
	var p Problem
	json.Unmarshal(rec.Body.Bytes(), &p)
	if rec.Code != 422 || len(p.Errors) != 3 || rec.Header().Get("Content-Type") != "application/problem+json" {
		t.Fatalf("status %d, problem %+v", rec.Code, p)
	}
}

func TestPatchTouchesOnlyWhatWasSent(t *testing.T) {
	h := newAPI(t)
	do(h, "POST", "/v1/tasks", `{"title":"keep me","notes":"important","due_date":"2026-01-02"}`)
	rec := do(h, "PATCH", "/v1/tasks/1", `{"status":"done"}`)
	var got Task
	json.Unmarshal(rec.Body.Bytes(), &got)
	if got.Status != "done" || got.Title != "keep me" || got.Notes != "important" || got.DueDate == nil || *got.DueDate != "2026-01-02" {
		t.Fatalf("patch changed more than it should have: %+v", got)
	}
}

func TestPagingVisitsEveryTaskOnceInOrder(t *testing.T) {
	h := newAPI(t)
	for i := 0; i < 11; i++ {
		do(h, "POST", "/v1/tasks", `{"title":"t"}`)
	}
	var ids []int64
	path := "/v1/tasks?limit=4"
	for pages := 0; pages < 10; pages++ {
		var resp listResponse
		json.Unmarshal(do(h, "GET", path, "").Body.Bytes(), &resp)
		for _, task := range resp.Data {
			ids = append(ids, task.ID)
		}
		if resp.NextCursor == "" {
			break
		}
		path = "/v1/tasks?limit=4&cursor=" + resp.NextCursor
	}
	if len(ids) != 11 {
		t.Fatalf("saw %v", ids)
	}
	for i, id := range ids {
		if id != int64(i+1) {
			t.Fatalf("out of order or repeated: %v", ids)
		}
	}
}

func TestEmptyListIsAnEmptyArray(t *testing.T) {
	rec := do(newAPI(t), "GET", "/v1/tasks", "")
	if !strings.Contains(rec.Body.String(), `"data":[]`) {
		t.Fatalf("body %s", rec.Body)
	}
}

func TestEveryErrorIsAProblemDocument(t *testing.T) {
	h := newAPI(t)
	for _, c := range []struct {
		method, path, body string
		status             int
	}{
		{"GET", "/v1/tasks/9", "", 404}, {"GET", "/v1/tasks/x", "", 400}, {"POST", "/v1/tasks", `{`, 400},
		{"POST", "/v1/tasks", `{"title":"a","extra":1}`, 400}, {"GET", "/v1/tasks?limit=0", "", 400},
	} {
		rec := do(h, c.method, c.path, c.body)
		var p Problem
		if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil || rec.Code != c.status || p.Status != c.status || p.Title == "" || p.Type == "" {
			t.Errorf("%s %s: status %d, body %s", c.method, c.path, rec.Code, rec.Body)
		}
	}
}

func TestMuxErrorsAreProblemDocumentsToo(t *testing.T) {
	h := newAPI(t)
	for _, c := range []struct {
		method, path string
		status       int
	}{{"PUT", "/v1/tasks/1", 405}, {"GET", "/v1/projects", 404}} {
		body := ""
		if c.method == "PUT" {
			body = "{}" // JSONOnly runs before routing, so a PUT without a JSON body would be a 415 first
		}
		rec := do(h, c.method, c.path, body)
		var p Problem
		if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil || rec.Code != c.status || p.Status != c.status || rec.Header().Get("Content-Type") != "application/problem+json" {
			t.Errorf("%s %s: status %d, content type %q, body %s", c.method, c.path, rec.Code, rec.Header().Get("Content-Type"), rec.Body)
		}
	}
	if allow := do(h, "PUT", "/v1/tasks/1", "{}").Header().Get("Allow"); !strings.Contains(allow, "PATCH") {
		t.Errorf("Allow = %q; the mux's header must survive", allow)
	}
}

func TestAuthIsRequiredUnderV1Only(t *testing.T) {
	h := newAPI(t)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/tasks", nil))
	if rec.Code != 401 || rec.Header().Get("WWW-Authenticate") == "" {
		t.Fatalf("status %d", rec.Code)
	}
	rec = httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest("GET", "/healthz", nil))
	if rec.Code != 200 {
		t.Fatalf("health status %d", rec.Code)
	}
}
Shell
go test -race -count=1 -v ./... 2>&1 | grep -E "^(--- |ok|FAIL|PASS)"
Text
--- PASS: TestValidationReportsEveryFieldAtOnce (0.01s)
--- PASS: TestPatchTouchesOnlyWhatWasSent (0.00s)
--- PASS: TestPagingVisitsEveryTaskOnceInOrder (0.01s)
--- PASS: TestEmptyListIsAnEmptyArray (0.00s)
--- PASS: TestEveryErrorIsAProblemDocument (0.00s)
--- PASS: TestMuxErrorsAreProblemDocumentsToo (0.00s)
--- PASS: TestAuthIsRequiredUnderV1Only (0.00s)
PASS
ok  	example.test/lab	1.401s

Each test states one rule of the contract. Validation reports every field at once, with the right content type. A patch changes only what was sent. Paging visits each of eleven tasks exactly once, in order, across several pages. An empty list is []. Every error, including the mux's own, is a problem document with a status that matches its body. And authentication guards /v1/ but not /healthz. Run them with -race and in CI on every change. The techniques for larger suites are in testing API handlers and integration tests against a real database; for a quick measurement of handler cost, benchmarking an API handler.

Choices in this API you may want to change

Every sample API embeds choices that suit its size. These are the ones I would revisit first.

Sequential integer ids. They are simple and they sort, and they are also guessable. If any endpoint can be called by someone who should see only their own tasks, a sequential id invites enumeration, and the real defense is the ownership check on every access, not the shape of the id. Random public identifiers (UUIDs, or shorter random strings) remove the temptation and avoid leaking how many tasks exist; keep an internal integer key for joins if you like. Object-level authorization is the topic that matters here.

Timestamps and dates. All timestamps are stored and returned in UTC as RFC 3339 strings, and the due date is a plain YYYY-MM-DD string because it is a calendar date and not an instant. Mixing the two ideas (a due date stored as midnight UTC) is the source of the classic bug where a task due "on the 31st" appears on the 30th for someone west of Greenwich. Decide which of the two each field is, and name the format in the contract.

One user, no ownership. The API has clients (API keys) and no users, so every key sees every task. A real service scopes each query by the owner taken from the authenticated principal in the context, never from a request parameter, and the store methods gain an owner argument. Retrofitting that is painful; adding it on day one costs one parameter.

The data envelope. Lists are wrapped as {"data": [...], "next_cursor": ...} so metadata has a home. Single resources are bare objects. Some teams wrap everything in an envelope for uniformity, and some return bare arrays. The wrong choice is mixing styles between endpoints; see response structure.

Hard deletes. DELETE removes the row. Many products need recovery or an audit trail, in which case deletion sets a timestamp and every query filters on it, as in soft delete with audit. Decide before launch, because adding it later changes every query.

SQLite and a single connection. Fine for a small service and for tests, and a limit for anything that wants concurrent writers. The store is the only place that knows, so moving to PostgreSQL changes store.go and its tests, plus the error check for constraint violations, and nothing in the handlers.

What to add before this faces the internet

The API above is a sound skeleton. These are the things I would add before real traffic, roughly in the order they bite, each with the article that does it properly.

Versioning and compatibility. The /v1/ prefix gives you a place to put breaking changes, and most changes should not be breaking: add fields, never remove or repurpose them, and keep old behavior for old clients until you can retire it deliberately. API versioning compares the strategies, and backward compatibility and deprecation cover how to evolve and retire endpoints with Sunset headers and notice.

Idempotency for unsafe retries. Networks fail between a request and its response, and a client that retries a POST creates a duplicate. An Idempotency-Key header, stored with the result, makes the retry return the original response (idempotency keys). Pair it with the client-side rules in retries and backoff.

Rate limits and quotas. Per-key limits protect you from a runaway client and let you sell tiers; rate limiting per API key and per route and usage metering show the algorithms, and the response should carry Retry-After. A hand-rolled limiter belongs in middleware like the others above.

Caching and conditional requests. ETag with If-None-Match turns repeated reads into 304s (conditional GET for APIs), and a Redis cache in front of expensive reads needs the invalidation discipline in caching API responses.

A machine-readable description. An OpenAPI document lets clients generate code and lets you test the contract. You can write the spec first and generate the server (oapi-codegen) or generate the spec from code (OpenAPI for Go services), and embed interactive documentation in the binary (Swagger UI). Whichever direction, keep one source of truth.

Security review. The list that matters most for a CRUD API: object-level authorization on every id (a user must not read another user's task by guessing its number, broken access control and IDOR), mass assignment (the decode into a dedicated input struct, as here, is the defense; mass assignment), SSRF if any endpoint fetches URLs (SSRF), limits on JSON depth and size (JSON depth limits), and secrets kept out of logs (redacting logs). The API security checklist puts them in one list.

Observability. Structured logs with the request id are in the example; add metrics (request counts, latency histograms by route and status), tracing across services (OpenTelemetry for APIs), and service level objectives that say what "working" means (API SLOs).

Operations. Configuration from the environment (configuration), health and readiness endpoints that separate "alive" from "ready" (health checks), graceful shutdown that drains requests (graceful shutdown), a load test before launch (load testing an API), and the final pass in the API launch checklist.

Longer-running work. An endpoint that starts a job should return 202 Accepted and a status URL rather than holding the connection (async jobs with 202), and clients that need to be told when something happens want webhooks with signatures and retries (sending webhooks).

Evolving the API without breaking anyone

A short habit list for the first year after launch. Add fields freely; clients are told to ignore what they do not know. Never change the meaning of an existing field, even when the new meaning seems better: introduce a new field and deprecate the old. Never tighten validation on an existing endpoint without a notice period, because a request that was valid yesterday and is a 422 today looks, to its sender, like an outage. When you must break something, do it in a new version and keep the old one running with a published end date. Keep a changelog that clients can read, and track which keys still call deprecated endpoints, which the client field in the access log makes possible. These habits are cheap compared with the cost of a client you cannot upgrade, and the structure above, a version in the path, one error format and a client name in every log line, is what makes them practical.

Decisions that age well

Looking back over the build, a handful of choices will still look right in two years, and they are mostly the unglamorous ones.

A written contract. The table at the top is more durable than any code under it. When behavior and contract disagree, one of them is a bug, and having the contract makes it possible to say which.

One error format with stable types. It is the cheapest decision to make early and the most expensive to retrofit, because every client will have been written against whatever you returned first.

Strict input, tolerant output. The server rejects unknown fields and bad values with precise messages. Clients should ignore fields they do not recognise, so the server can add them, and you should say so in the documentation.

Opaque cursors and a hard maximum page size. They keep the paging implementation changeable and keep one client from asking for everything.

Thin handlers. Each handler parses, validates, calls the store and writes the result. Business rules that grow beyond a few lines move into a service layer that handlers call, tested without HTTP.

Tests through the real chain. The bugs found while writing this article, a plain-text 405 in a JSON API, an empty client in the access log, a 415 that precedes a 405, all lived between layers. They were found by running the assembled thing and are now pinned by tests that run it.

For the mistakes that undo these, read common REST API mistakes in Go; for a worked example with a bigger domain, a CRUD API with only the standard library; and for the libraries worth considering when you outgrow the standard ones, the best Go API libraries. The routing and handler APIs used throughout are documented in the net/http package reference, and the error format in RFC 9457.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.