Skip to content

Contract Primer

Open this page after Stable Roots when the change still belongs to the default service path and the real question has narrowed to how the service shapes its HTTP surface: what it writes back, how it signals errors, and what it carries in request context.

contract owns the structured error model, canonical response helpers, and request-scoped context accessors. It is the only permitted import for modules that must stay at the transport boundary without pulling in app construction, routing, or persistence.

func Greet(w http.ResponseWriter, r *http.Request) {
rc := contract.RequestContextFromContext(r.Context())
name := rc.Params["name"]
_ = contract.WriteResponse(w, r, http.StatusOK,
map[string]string{"message": "hello, " + name}, nil)
}
func Fail(w http.ResponseWriter, r *http.Request) {
_ = contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeNotFound).
Message("user not found").
Build())
}
  • you are writing or changing WriteError or WriteResponse output shape
  • you are adding or modifying an error category or error type
  • you are reading or writing request-scoped metadata such as request ID, route params, or trace headers
  • the change is about route matching or URL parameter extraction policy — that belongs in router
  • the change introduces session lifecycle, token issuance, or auth identity — that belongs in security
  • the work is about how middleware observes or transforms requests — start from middleware
  • the change adds protocol-gateway behavior, multipart upload convenience, or feature-specific response envelopes

First files to read in the current repository

Section titled “First files to read in the current repository”
  1. contract/module.yaml
  2. contract/response.go
  3. contract/errors.go
  4. contract/context_core.go
  5. reference/standard-service/internal/handler/health.go
Keep it in contract when the work is about Move out when the work becomes
WriteError / WriteResponse — one canonical write path feature-specific response envelopes with per-feature constructors
ErrorCategory, ErrorType constants and the APIError shape per-feature error registries or ad hoc error constructor families
RequestContext with Params, RoutePattern, RouteName — transport metadata only mutable request bags, abort state, or context service-locator helpers
request ID and trace metadata carriers middleware policy, request-id generation policy, or field redaction
import "github.com/spcent/plumego/contract"
func (h APIHandler) GetUser(w http.ResponseWriter, r *http.Request) {
user := fetchUser(r.Context())
contract.WriteResponse(w, r, http.StatusOK, user, nil)
}

WriteResponse wraps the data in {"data": ..., "request_id": "..."} and sets Content-Type: application/json. The request_id field is populated automatically when the requestid middleware has run.

Include metadata in the meta argument:

contract.WriteResponse(w, r, http.StatusOK, items, map[string]any{
"total": 120,
"page": 2,
})
func (h APIHandler) Greet(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
contract.WriteError(w, r, contract.NewErrorBuilder().
Type(contract.TypeRequired).
Detail("field", "name").
Message("name is required").
Build())
return
}
contract.WriteResponse(w, r, http.StatusOK, map[string]string{"message": "hello, " + name}, nil)
}

Type sets the HTTP status, error code, and category automatically. Message overrides the human-readable text. Detail adds fields to the details map.

Constant HTTP status Use when
TypeRequired 400 Required param or body field is absent
TypeValidation 400 Field present but fails validation
TypeInvalidFormat 400 Format wrong (UUID, email, etc.)
TypeOutOfRange 400 Value out of allowed range
TypeDuplicate 400 Value already exists
TypeUnauthorized 401 Request is not authenticated
TypeInvalidToken 401 Token is malformed or invalid
TypeExpiredToken 401 Token has expired
TypeForbidden 403 Authenticated but lacks permission
TypeNotFound 404 Resource not found
TypeConflict 409 State conflict (e.g. concurrent update)
TypeAlreadyExists 409 Resource already exists
TypeGone 410 Resource permanently deleted
TypeRateLimited 429 Rate limit exceeded
TypeInternal 500 Server-side failure
TypeUnavailable 503 Downstream dependency unavailable
TypeTimeout 504 Upstream call timed out

Every error response uses this envelope:

{
"error": {
"type": "required_field_missing",
"code": "REQUIRED",
"message": "name is required",
"category": "validation_error",
"details": { "field": "name" }
},
"request_id": "abc-123"
}
// Request ID injected by middleware/requestid
rid := contract.RequestIDFromContext(r.Context())
// Route metadata set by router
rc := contract.RequestContextFromContext(r.Context())
id := rc.Params["id"]
// Query parameter with default
page := r.URL.Query().Get("page")
if page == "" {
page = "1"
}
Mistake Correct pattern
Using WriteResponse for errors Use WriteError — it sets the correct HTTP status automatically
Forgetting return after WriteError Always return immediately — the response is already written
Constructing APIError as a struct literal Use NewErrorBuilder() — struct layout is not part of the stable API
Logging the full error string in the message Log internally; use a fixed human message in WriteError
Using TypeInternal for client mistakes Choose the specific type: TypeRequired, TypeNotFound, etc.
Missing request_id in error responses Ensure middleware/requestid is registered before Prepare

contract is the lowest-level import allowed in stable roots: only stdlib. That constraint means it is the shared language for every handler in the system. Breaking the error shape or response envelope is a cross-repo regression. Changes here ripple into every consumer. Treat contract as a published API surface, not an implementation detail.