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 aboutMove out when the work becomes
WriteError / WriteResponse — one canonical write pathfeature-specific response envelopes with per-feature constructors
ErrorCategory, ErrorType constants and the APIError shapeper-feature error registries or ad hoc error constructor families
RequestContext with Params, RoutePattern, RouteName — transport metadata onlymutable request bags, abort state, or context service-locator helpers
request ID and trace metadata carriersmiddleware 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.

ConstantHTTP statusUse when
TypeRequired400Required param or body field is absent
TypeValidation400Field present but fails validation
TypeInvalidFormat400Format wrong (UUID, email, etc.)
TypeOutOfRange400Value out of allowed range
TypeDuplicate400Value already exists
TypeUnauthorized401Request is not authenticated
TypeInvalidToken401Token is malformed or invalid
TypeExpiredToken401Token has expired
TypeForbidden403Authenticated but lacks permission
TypeNotFound404Resource not found
TypeConflict409State conflict (e.g. concurrent update)
TypeAlreadyExists409Resource already exists
TypeGone410Resource permanently deleted
TypeRateLimited429Rate limit exceeded
TypeInternal500Server-side failure
TypeUnavailable503Downstream dependency unavailable
TypeTimeout504Upstream 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"
}
MistakeCorrect pattern
Using WriteResponse for errorsUse WriteError — it sets the correct HTTP status automatically
Forgetting return after WriteErrorAlways return immediately — the response is already written
Constructing APIError as a struct literalUse NewErrorBuilder() — struct layout is not part of the stable API
Logging the full error string in the messageLog internally; use a fixed human message in WriteError
Using TypeInternal for client mistakesChoose the specific type: TypeRequired, TypeNotFound, etc.
Missing request_id in error responsesEnsure 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.