Contract Primer
Contract Primer
Section titled “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())}Start here when
Section titled “Start here when”- you are writing or changing
WriteErrororWriteResponseoutput 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
Do not start here when
Section titled “Do not start here when”- 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”contract/module.yamlcontract/response.gocontract/errors.gocontract/context_core.goreference/standard-service/internal/handler/health.go
Concrete ownership examples
Section titled “Concrete ownership examples”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
Section titled “Import”import "github.com/spcent/plumego/contract"Write a success response
Section titled “Write a success response”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,})Write an error response
Section titled “Write an error response”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.
ErrorType catalog
Section titled “ErrorType catalog”| 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 |
Wire format
Section titled “Wire format”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"}Read context helpers
Section titled “Read context helpers”// Request ID injected by middleware/requestidrid := contract.RequestIDFromContext(r.Context())
// Route metadata set by routerrc := contract.RequestContextFromContext(r.Context())id := rc.Params["id"]
// Query parameter with defaultpage := r.URL.Query().Get("page")if page == "" { page = "1"}Common pitfalls
Section titled “Common pitfalls”| 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 |
Why this primer exists
Section titled “Why this primer exists”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.
Read next
Section titled “Read next”- contract API Quick Reference — complete function signatures, all ErrorType values, and wire formats
- Error Reference — all error types with trigger scenarios and remediation
- Error Model
- Handle Errors guide
- Core Primer
- Router Primer
- Middleware Primer
- Repository Boundaries