paste this link into your ai. it will know what to do.
REST and gRPC API design patterns for Go services. Covers HTTP handlers, middleware, routing, request/response patterns, versioning, pagination, graceful shutdown, and OpenAPI documentation. Use when designing APIs, writing HTTP handlers, implementing middleware, structuring REST endpoints, or setting up gRPC services. Trigger examples: "design API", "REST endpoints", "HTTP handler", "middleware pattern", "graceful shutdown", "gRPC service", "API versioning". Not for: general architecture (go-architecture-review), concurrency in handlers (go-concurrency-review).
Go API Design
APIs are contracts. Once published, they're promises. Design them as if you'll maintain them for a decade — because you probably will.
Community default. A project skill or agent-config rule that explicitly supersedes go-api-design takes precedence over this one.Detailed reference material, loaded on demand:
references/handlers-and-middleware.md— handler structs, JSON response
helpers, request ID and recoverer middleware, decode-and-validate.
Read a reference file only when the summary below is not enough.
1. HTTP Handler Structure
- Route with the standard
http.ServeMux(Go 1.22+):"GET /users/{id}"
patterns match the method, r.PathValue("id") reads the wildcard, and a wrong method gets a 405 with Allow. A router dependency earns its place for middleware groups and sub-routers, not for path parameters.
- Handlers are methods on a struct that holds their dependencies (store,
logger) — never package globals.
- Handler methods return nothing: they write the response and handle the
error themselves, mapping domain errors (errors.Is(err, ErrNotFound)) to status codes and logging only the unexpected ones.
- Route through one JSON helper that sets
Content-Type, writes the status
once, and logs encode failures.
2. Middleware Pattern
Middleware is func(http.Handler) http.Handler. Order, outside → inside:
Recoverer → RequestID → Logger → Auth → RateLimit → HandlerRecover MUST be outermost. Auth before business logic. Logger captures timing.
3. Request Validation
- Cap the body before decoding:
r.Body = http.MaxBytesReader(w, r.Body, 1<<20). - Decode into a typed request struct, then validate it in the same step —
a successful decode is not a valid value.
- Reply
400with a stable message; never echo the decoder's error text.
4. URL and Naming Conventions
GET /api/v1/users → list users
POST /api/v1/users → create user
GET /api/v1/users/{id} → get user
PUT /api/v1/users/{id} → replace user
PATCH /api/v1/users/{id} → partial update
DELETE /api/v1/users/{id} → delete user
GET /api/v1/users/{id}/orders → list user orders (nested resource)Rules:
- Plural nouns for resources:
/users, not/user - Kebab-case for multi-word paths:
/order-items - One JSON field convention per API, never mixed — camelCase
("createdAt") is the default here; match an existing API if one exists
- Version in URL path:
/api/v1/... - No verbs in URLs:
/users/search?q=alice, NOT/searchUsers
5. Pagination
type PageRequest struct {
Cursor string `json:"cursor"`
Limit int `json:"limit"`
}
type PageResponse[T any] struct {
Items []T `json:"items"`
NextCursor string `json:"nextCursor,omitempty"`
HasMore bool `json:"hasMore"`
}Prefer cursor-based pagination over offset/limit for large datasets. Offset pagination breaks under concurrent writes.
6. Graceful Shutdown
func main() {
if err := run(context.Background()); err != nil {
slog.Error("server stopped", slog.Any("error", err))
os.Exit(1)
}
}
func run(ctx context.Context) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{
Addr: ":8080",
Handler: router,
ReadHeaderTimeout: 5 * time.Second, // Slowloris guard
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 120 * time.Second,
}
errc := make(chan error, 1)
go func() { errc <- srv.ListenAndServe() }()
select {
case err := <-errc:
return fmt.Errorf("listen: %w", err) // e.g. port already in use
case <-ctx.Done():
}
// ctx is already cancelled; shutdown needs its own deadline.
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}ReadHeaderTimeoutis the Slowloris defence: without it a client can
hold a connection open by trickling header bytes. gosec G112 flags a server that lacks it.
- Compare
http.ErrServerClosedwitherrors.Is, never==. - Exit only from
main. Alog.Fatalinside the serving goroutine skips
every deferred cleanup, including the shutdown itself.
7. Health Check Endpoints
// Liveness: is the process alive? GET /healthz → 200, no dependency checks
// Readiness: should it receive traffic? GET /readyz → 200 or 503
func (h *HealthHandler) handleReady(w http.ResponseWriter, r *http.Request) {
if h.draining.Load() { // atomic.Bool, set when shutdown starts
h.respondError(w, http.StatusServiceUnavailable, "shutting down")
return
}
if err := h.db.PingContext(r.Context()); err != nil {
h.respondError(w, http.StatusServiceUnavailable, "database unavailable")
return
}
h.respondJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}On SIGTERM behind a load balancer: set draining, wait one readiness period so the balancer stops routing, then call srv.Shutdown. Shutting down first rejects requests the balancer is still sending. Keep liveness free of dependency checks — a database outage should not make the orchestrator restart every replica.
8. Error Response Format
Consistent error responses across the entire API:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "invalid request parameters",
"details": [
{"field": "email", "message": "must be a valid email"}
]
}
}Map internal errors to HTTP status codes at the handler boundary. Internal errors should NEVER leak to clients. For a standard shape, use RFC 9457 problem details (application/problem+json) — the go-openapi skill has the type.
9. Idempotency and Browser Clients
POSTendpoints that create or charge accept anIdempotency-Key
header: store key → response for a bounded time, replay the stored response for a repeated key, and reject a concurrent duplicate with 409. Clients and proxies retry; without this, a retry is a double charge.
- APIs called from browsers with cookies need CSRF protection —
http.CrossOriginProtection (Go 1.25+); the go-security-audit skill owns the details.
keep it where your ai can reach it.
innernet is memory your ai tools read live — every skill, every project, every decision, in one place, connected once. save this skill to yours, or publish one of your own as a link like this.