Decorators
Decorators attach metadata to declarations and fields. Every decorator starts with @ and is registered in a closed set; @unknown fires a decorator/unknown diagnostic at semantic analysis time.
At a glance
53 decorators, grouped by where they apply. Each decorator declares one or more sites (file, type, field, service, method, ...) and an argument shape (none, string, number, ident, list, ...). Using a decorator at the wrong site or with the wrong arguments fires a diagnostic with the line and column. A decorator is never another decorator's argument: @doc(@minLength(1)) is a parse error (a decorator cannot be an argument of @doc).
@version("1.0.0") // file
package design
middleware Auth
type GetUserReq {
id string
}
type User { // type
id string // field
name string @length(1, 80)
email string @format(email)
}
@prefix("/v1") // service
@middlewares(Auth)
service UserService {
@doc("Get user.") // method
@timeout(5s)
get GetUser /users/{id} {
request GetUserReq
response User
}
}This page is the example-driven walkthrough - what each decorator means, with snippets and the inheritance / opt-out mechanics. For a scannable lookup table (every decorator's levels, args, and effect in one grid), see the Decorator Registry.
Names from other tools
The decorator set is closed - an unknown decorator fires decorator/unknown. If you're coming from JSON Schema, Zod, OpenAPI, or class-validator, a few reflexes map to different spellings:
| You might reach for | craftgo uses | Why |
|---|---|---|
@min(n) / @max(n) | @gte(n) / @lte(n) (inclusive), @gt / @lt (strict) | One spelling for inclusive vs strict, consistent with @range(min, max). |
@required | nothing - fields are required by default | A field is required unless its type carries ?. Use ? to opt out. |
title / externalDocs on an operation | craftgo.design.yaml openapi.title; @doc("… https://…") | Document-level metadata lives in the manifest, not per-decorator. |
Sites
| Site name | Where the decorator sits |
|---|---|
file | Above the package line at the top of a .craftgo file |
type | Above a type declaration |
field | Above a field in a type body, or after it on its line |
enum | Above an enum declaration |
enumValue | Above an enum value, or after it on its line (Active @doc("...")) |
error | Above an error declaration |
errorField | Above a field in an error body, or after it on its line |
scalar | Above a scalar declaration, or after it on its line |
service | Above a service declaration |
method | Above an HTTP method inside a service body |
middleware | Above a middleware declaration |
event | Above an event declaration |
A decorator on a line of its own decorates what is below it, across blank lines and comments; one on a field's or an enum value's line after its type or value decorates that member:
type T {
x string @minLength(1)
@maxLength(5)
y string
}Here @minLength(1) is x's and @maxLength(5) is y's. Decorators on lines of their own with no member below them in a body are a parse error, and a mixin takes none, above it or on its line.
Documentation and lifecycle
@doc(text)
Free-form documentation; text is a string. It becomes the Go doc comment of what craftgo generates for the declaration - types, fields, scalars, enums and enum values, errors, middlewares, events, and a method's handler and service stub - set apart from the line craftgo writes itself by an empty // line (// Get user. / // / // GetUser returns the GET GetUser handler.). It is also the OpenAPI description of a file (info.description), type, field, error field, scalar, enum and method; a service's @doc reaches no generated output. The editor hover shows the // comment above a declaration, not its @doc.
| Sites | file, type, field, service, method, enum, enumValue, error, errorField, scalar, middleware, event |
|---|---|
| Args | (string) |
@doc("The user entity. Email is the canonical login id.")
type User { ... }Doc-comments above a declaration produce the same Go doc and OpenAPI description:
// The user entity. Email is the canonical login id.
type User { ... }When a declaration has both, @doc wins in the generated output, and the hover shows the comment.
@deprecated / @deprecated("reason")
Marks the construct as deprecated. OpenAPI sets deprecated: true on a type's schema, on a field's property or parameter (the reason appended to its description), and on a method's operation; a service's @deprecated marks each of its operations. In Go, a type or a field (error-body fields included) gains a // Deprecated: <reason> paragraph, which staticcheck and gopls recognise. On a file, an enum value, a middleware or an event it changes no generated output.
| Sites | file, type, field, service, method, enumValue, errorField, middleware, event |
|---|---|
| Args | () or (string) |
@deprecated("Use email instead.")
type LegacyUserReq { ... }
type User {
legacyId string @deprecated
email string
}@example(value)
Example value rendered in the OpenAPI schema for this field.
| Sites | field, errorField |
|---|---|
| Args | a literal (string / int / float / bool / null) or an array of those |
type User {
name string @example("alice")
tier string @example("gold")
roles string[] @example(["admin", "editor"])
}An object literal (@example({...})) is rejected - a struct's example is composed by OpenAPI tooling from each field's own @example, so document the fields individually rather than the whole object.
OpenAPI file-header
@version(text)
Set the OpenAPI document version from a design file. A design has one document: the first file, in path order, that carries @version sets info.version, and the others' are ignored. Without it, the value comes from craftgo.design.yaml's openapi.version. A file-level @doc sets info.description the same way. The document title is set exclusively via the manifest's openapi.title.
| Sites | file |
|---|---|
| Args | (string) |
@version("2.0.0")
package designCross-field type rules
@requiresOneOf(field1, field2, ...)
At least one of the listed fields must be present.
| Sites | type |
|---|---|
| Args | one or more idents (or one array literal) |
@requiresOneOf(email, phone)
type Contact {
email string?
phone string?
}@mutuallyExclusive(field1, field2, ...)
At most one of the listed fields may be present.
| Sites | type |
|---|---|
| Args | one or more idents (or one array literal) |
@mutuallyExclusive(personal, business)
type Account {
personal bool?
business bool?
}Listed fields must be pointer-backed
Each referenced field must be optional (?) or @nullable so it has an unambiguous present/absent state. A plain field (checked by zero-value emptiness), a @query / @header / @cookie / @form parameter, a @default field, a @sensitive field, and a collection (T[] / map<…> / bytes / any) are all rejected at design time - their runtime presence can't match the present-and-non-null the OpenAPI fragment advertises.
Field validators
Required-by-default: every field is required unless its type carries the
?suffix. There is no@requireddecorator - to mark a field optional, writename string?. To allownullwhile keeping the field mandatory, add@nullable. To fill a value left out or sent asnull, add@default(...)(which also auto-marks the field optional on save).
Error-body validators are spec-only. Validators (
@minLength,@pattern,@range, ...) onerrorbody fields surface in the generated OpenAPI schema constraints but produce no runtime check - errors are emitted server-side from your handler, so the framework cannot validate something it just constructed.validate.godoes generate aValidate()on the error's<Name>Body, which your handler may call beforeNew<Name>Err(...); nothing else calls it. Treat the constraints on error fields as documentation contracts that consumer SDKs read.
Strings
@length, @minLength and @maxLength run on string and bytes fields, and on scalars whose primitive is one of those; @pattern and @format on string only (a bytes field takes @format(raw) alone).
| Decorator | Args | Effect |
|---|---|---|
@length(min, max) | (int, int) | Length in [min, max] inclusive |
@length(n) | (int) | Length exactly n |
@minLength(n) | (int) | Length >= n |
@maxLength(n) | (int) | Length <= n |
@pattern("regex") | (string) | RE2-flavored regex match |
@format(name) | bare ident (a quoted name draws decorator/arg-prefer-ident, which craftgo fmt rewrites) | Named format check (see below) |
On a string field, length validators count Unicode characters (runes), not bytes - the generated Go validator uses utf8.RuneCountInString(s). This matches the OpenAPI minLength / maxLength keyword (JSON Schema counts characters) and a Postgres varchar(n) (also characters), so the runtime, the spec, and the database agree. "日本語" (3 characters / 9 bytes) passes @maxLength(3). To cap the raw network size instead, use @maxBodySize (which polices bytes).
On a bytes field, length validators count bytes (len(b)) - the binary length - and are not advertised in the OpenAPI schema (an OpenAPI maxLength on a bytes field would count base64 characters, a different number).
Available formats (@format(...)): email, url, uri, uuid, datetime (RFC 3339), date, time, phone, ipv4, ipv6, cidr, mac, creditcard, base64, base64url, hexcolor, json. Each follows its RFC, as the OpenAPI format of the same name does; uuid, creditcard and hexcolor use a regex, and the others a check function built on the Go standard library. Validators lists what each accepts.
One more value is not a check at all: @format(raw), valid only on a bytes field, says the bytes already ARE the value in the message's own encoding and the codec must embed them untouched.
type User {
email string @format(email) @maxLength(254)
website string @format(uri)
avatar string @pattern("^https://.*\\.(png|jpg)$")
}Numbers
Run on int / uint / float fields and scalars wrapping them; @multipleOf on integers only.
| Decorator | Args | Effect |
|---|---|---|
@gte(n) | (number) | Value >= n |
@gt(n) | (number) | Value > n |
@lte(n) | (number) | Value <= n |
@lt(n) | (number) | Value < n |
@range(min, max) | (number, number) | Both bounds inclusive (@gte + @lte) |
@positive | () | Value > 0 |
@negative | () | Value < 0 |
@multipleOf(n) | (number) | Value divisible by n |
type Order {
quantity int @positive @lte(1000)
price int @gte(0) @multipleOf(2)
rating float64 @range(0.0, 5.0)
}Arrays and maps
Run on array / map fields.
| Decorator | Args | Effect |
|---|---|---|
@minItems(n) | (int) | At least n elements |
@maxItems(n) | (int) | At most n elements |
@uniqueItems | () | All elements distinct (arrays only) |
type Post {
tags string[] @minItems(1) @maxItems(10) @uniqueItems
}File uploads
Run on file fields (a request that holds one is multipart/form-data, with or without @form).
| Decorator | Args | Effect |
|---|---|---|
@maxSize(bytes) | (size) | Cap upload size, enforced while the part is read. Accepts 2MB, 8KB, etc. |
@mimeTypes([...]) | string array | Allowed media types or type/* ranges |
type AvatarReq {
userId string @path
file file @form @maxSize(2MB) @mimeTypes(["image/png", "image/jpeg"])
}Field metadata
@nullable
Marks the field as accepting an explicit JSON null. Generated Go: pointer wrap if the base type is not already nilable.
| Sites | field, errorField |
|---|---|
| Args | () |
type Patch {
name string @nullable
// Wire: "name": null is a legal value; Go: Name *string `json:"name"`
}On a ? field @nullable is redundant (decorator/redundant): ? already allows null.
@default(value)
Fill the field when the client leaves it out, in a request type or a type nested in its body; a value the client sends wins. On an error field, New<Name>Err gives an optional field the body leaves nil its default.
| Sites | field, errorField |
|---|---|
| Args | (any) - a literal matching the field's primitive, an enum value's bare ident, or an array literal |
Works on:
- Plain primitives (
string, the integers and floats,bool) - Scalars wrapping them
- Enums (use the bare value name:
@default(Active)) - Single-level arrays of any of the above (
@default([]),@default(["a", "b"]),@default([Active, Pending]))
Conflicts: @path (a matched route always supplies the segment, so the default could never apply); bytes, datetime, file and any fields, maps, structs, generic fields and nested arrays are refused too (decorator/conflict). Beside @query, @header, @cookie or @form the handler pre-fills the value before binding. The default fires with or without ?, and OpenAPI leaves the field out of required either way; without ? the analyzer warns (decorator/default-needs-optional), and the formatter adds the ? on save, which makes the Go field a pointer and lets the schema admit null.
The default must pass the field's own validators and its scalar's: @default(0) beside @positive, or @default(["a"]) beside @minItems(2), is decorator/conflict. An array default is held to @minItems, @maxItems and @uniqueItems, and each element to its scalar's validators. An enum member is checked as its wire value: with enum Prio { Low = 1 High = 2 }, @default(Low) beside @range(2, 9) is decorator/conflict.
type ListReq {
page int? @default(1)
pageSize int? @default(20) @gte(1) @lte(100)
status Status? @default(Pending)
tags string[]? @default([])
}@json("key")
The JSON key of a body field when it is not the field name - a contract another system owns, or a key the parser would read as a mixin. The Go struct tag, the OpenAPI property and validation messages all carry it. A field bound off the body (@path, @query, @header, @cookie, @form) names its wire location in that decorator instead. The key is what encoding/json takes as a tag name: letters, digits, spaces and the punctuation !#$%&()*+-./:;<=>?@[]^_{|}~. Any other character, such as a quote, a comma, a backslash, a backtick, ' or €, would make encoding/json ignore the key and use the Go field name, so it is decorator/argvalue, as is the key -.
| Sites | field, errorField |
|---|---|
| Args | (string) |
type OrderCaptured {
storeId string @json("store_id") // Go: StoreID string `json:"store_id"`
}@sensitive
Server-only field: tagged json:"-" so neither the request decoder nor the response encoder touches it. Skipped from OpenAPI entirely, and from Validate(): an enum or a struct type keeps its own checks elsewhere, but not on this field.
| Sites | field, errorField |
|---|---|
| Args | () |
Conflicts: cannot combine with any wire-shaping decorator (validators, bindings, @json, @nullable, @default).
type Order {
id string
internalRef string @sensitive // populated by service code, never on the wire
}Field bindings
Tell the handler where to read each field from. Mutually exclusive (a field has one binding).
| Decorator | Sites | Reads from |
|---|---|---|
@body | field | Request body (JSON or form) |
@path | field | URL path parameter {name} |
@query | field | URL query string |
@header | field, errorField | Request header (input) / response header (error fields) |
@cookie | field, errorField | Request cookie (input) / response cookie (error fields) |
@form | field | Multipart form field |
An array @header reads a list header: every line of it, split at the commas outside a quoted string (X-Ids: 1, 2 and two X-Ids lines bind the same; "a,b", c is two elements, the first with its quotes). A @form text part is read from the multipart body only; a query parameter of the same name is not a stand-in for it.
@path, @query, @header, @cookie and @form take an optional string for an explicit wire name; @body("name") is accepted but names nothing - a body field's JSON key is set with @json("key"). A path, query, header or cookie name rides the generated struct tag, a Go raw string, so one holding a backtick is decorator/argvalue:
type GetUserReq {
id string @path // matches {id} segment
page int @query // ?page=
apiKey string @header("X-API-Key") // explicit header name
session string @cookie("sid") // explicit cookie name
}In the generated Go struct these fields carry json:"-" (they ride the URL / headers / cookies, never the JSON body) plus a binding key naming the wire location, so the struct documents where each value comes from:
type GetUserReq struct {
ID string `json:"-" path:"id"`
Page int `json:"-" query:"page"`
APIKey string `json:"-" header:"X-API-Key"`
Session string `json:"-" cookie:"sid"`
}The binding key's value is the explicit wire name when given, otherwise the field name. craftgo binds these fields by generated code, not by tag reflection - the key is documentary, so Go ignores it.
A field with no binding decorator whose name matches a {name} variable of the route (the @prefix's and the basePath's included) binds that path segment. Any other falls back to:
bodyfor body verbs (POST / PUT / PATCH)queryfor non-body verbs (GET / DELETE / HEAD / OPTIONS)
When the request carries a file, its body becomes multipart/form-data and every body or @form field rides a form part, which holds a string, a bool, a number, a scalar or enum over one, or a single-level array of those; a struct, a map, a generic instance or a nested array there is binding/type. A request with no file has no form parts, so @form there is binding/form-without-file: drop it and the field rides the JSON body.
A binding decorator binds only on a field of the request, response or error type itself, a mixin's fields included. A field of a type that one of them holds as a nested value rides inside the JSON body, where no binding reaches it, so @path, @query, @header, @cookie or @form on it is binding/nested: move the field up, or let a mixin bring it there.
Response-side bindings on response and error types. @header / @cookie on a response struct or error body field write the value onto w.Header() / a Set-Cookie instead of the JSON body, the cookie with the attributes its @cookie declares (below) - the JSON tag is automatically json:"-" (with a header: / cookie: binding key alongside) so the same field doesn't double up. Non-string values (int, bool, float, scalars and enums of those) are formatted to their wire string via strconv, just like the request-side binder parses them; an optional (T?) header is written only when non-nil, and a string[] header emits one value per element. The explicit-name argument applies here too:
type PaginatedResp {
items shared.ID[]
count int @header("X-Total-Count") // strconv.Itoa → X-Total-Count: 42
nextURL string? @header("X-Next") // only written when present
active bool @cookie("active") // Set-Cookie: active=true; Path=/; HttpOnly; Secure; SameSite=Lax
session string @cookie("session_id") // Set-Cookie: session_id=...; Path=/; HttpOnly; Secure; SameSite=Lax
}
error TooManyRequests RateLimitedErr {
retryAfter int @header("Retry-After") // error responses can ship custom headers
}Cookie attributes. A cookie the server sets, from a response's or an error's @cookie field, carries the attributes its @cookie names after the cookie's name; each one left out takes a safe default.
| Attribute | Value | Left out |
|---|---|---|
path | a path starting with / | "/", every path of the site |
domain | a host name, a leading . allowed | none: the host that set it alone |
maxAge | a duration of whole seconds (24h), or an int of seconds | none: the cookie lasts the browser session |
httpOnly | true or false | true: scripts cannot read it |
secure | true or false | true: sent over HTTPS only; browsers treat http://localhost as secure |
sameSite | lax, strict or none (which needs secure) | lax |
type Session {
token string @cookie("sid", maxAge: 24h, sameSite: strict)
theme string @cookie("theme", httpOnly: false) // scripts may read it
remember cookie? @cookie("remember", maxAge: 720h) // the logic decides, per request
}A field of a value type, as string, gives the cookie its value, and its attributes are the design's. A field typed cookie is an *http.Cookie the logic sets per response. craftgo generates New<Type><Field>Cookie(value), which returns the cookie with the declared attributes; the logic changes what the request decides, as a lifetime the user picked or MaxAge: -1 to delete the cookie at logout, and returns it:
func (l *LoginService) Login(req *types.LoginReq) (*types.Session, error) {
s := &types.Session{Token: token}
if req.RememberDays > 0 {
s.Remember = types.NewSessionRememberCookie(token)
s.Remember.MaxAge = req.RememberDays * 24 * 60 * 60
}
return s, nil
}The cookie goes out under the name the design gives it, whatever the logic set, and a nil cookie sets none. A cookie field must carry @cookie and sit on a response type or an error body, as a single value: not in a request, which reads a cookie's value alone, nor in an array, a map or a generic argument (binding/cookie-position). A request's @cookie field reads the value; attributes on it apply only where its type is a response.
Service decorators
@prefix(path)
Path prefix prepended to every method route in the service.
| Sites | service |
|---|---|
| Args | (string) |
@prefix("/v1")
service UserService {
get GetUser /users/{id} { ... } // -> GET /v1/users/{id}
}A path variable in the prefix is a whole segment, as in /orgs/{org}. A route net/http's ServeMux would refuse to register is rejected: a segment such as org-{org}, . or .., or a variable name that is no Go identifier (route/pattern), a {rest...} before the route's last segment (route/pattern), and a variable the basePath, the prefix or the method path repeats (route/duplicate-path-var).
A trailing {rest...} matches the rest of the path and is named rest: a request field rest, or one with @path("rest"), binds it. OpenAPI has no variable that spans segments, so the document lists rest as an ordinary path parameter, typed like its field (a plain string for a string field), in the path /…/{rest}.
@group(path)
Does two things: (1) sets where the service's generated files land on disk - the value replaces the service-name segment, so the files go to <output>/<group>/ instead of <output>/<service-name>/ - and (2) adds its value as an OpenAPI tag. It does not change the HTTP route or the OpenAPI path - use @prefix for that.
| Sites | service, extend service |
|---|---|
| Args | (string) |
@prefix("/v1/admin")
@group("admin/ops")
service AdminService {
get DashboardStats /dashboard { ... } // -> GET /v1/admin/dashboard
}With the above, the handler and service stub for DashboardStats are written to internal/transport/admin/ops/dashboard_stats.go and internal/service/admin/ops/dashboard_stats.go (the admin_service segment is gone - the group took its place), while the route stays /v1/admin/dashboard. The value is a relative path: a single segment (admin) or nested (admin/ops). The group moves everything keyed by service - transport handlers, service stubs, and the routes.go that registers them (internal/routes/admin/ops/routes.go). Types are unaffected: they stay under their DSL package, internal/types/<package>/.
The group is a namespace, and services may share it. Because it replaces the service name, two services that pick the same
@groupland in the same directory on purpose - that is how you gather a versioned or feature surface (@group("shared/v1")on bothAlphaandBeta) into one folder. Their handlers and stubs are per-method files that sit side by side, and the directory gets oneroutes.goregistering every contributor's methods, which the umbrellaroutes.gocalls once. Sharing works the same way when a@groupnames an ungrouped service's own directory (@group("beta")alongsideservice Beta).Two things a shared directory cannot absorb, both rejected at analysis time:
group/package-straddle- the contributors were declared in different DSL packages. Generated files take their Gopackagedeclaration from the DSL package, and a directory is one Go package, so the folder would hold two conflicting declarations. Put the services in one DSL package, or give them separate groups.group/method-collision- two contributors declare the same method name. Handlers and stubs are one file per method, named after it, and so is the handler function, so both would claim<method>.goand the same exported symbol. Rename one method.
The group value also rides along as an OpenAPI tag, appended to any explicit @tags and deduped. So @group("admin/ops") @tags(users) tags every operation [users, admin/ops]; @group("admin") @tags(admin) collapses to a single admin. @ignoreTags on a method drops the group tag along with the rest of the inherited service tags.
Because the move changes where the service stub is generated, set @group before you start filling in business logic: adding it later leaves your existing stub at the old path and scaffolds a fresh empty one under the group, and craftgo gen names the old directory (./internal/service/<old> holds logic stubs of a service the design no longer declares - …).
Per-block grouping. Unlike @prefix (primary-only), @group is also accepted on an extend service block, where it groups only that block's methods. This splits one service's code across version/feature folders. Each group folder gets its own routes.go registering just the methods that landed there; the umbrella routes.go calls into every one of them, so the split stays invisible to main.go:
@prefix("/checkout")
service Checkout {
post Pay /pay { request PayReq response Receipt } // -> transport/checkout/pay.go
}
@group("checkout/v2")
extend service Checkout {
post PayV2 /v2/pay { request PayReqV2 response Receipt } // -> transport/checkout/v2/pay_v2.go
}Typed errors are unaffected by grouping: errors.go is emitted once per DSL package under types/<package>/, and every group package reaches the runtime's server.WriteError, so the groups do not duplicate it.
@middlewares(name1, name2, ...)
Apply named middlewares. On a service, the chain runs on every method. On a method, the chain appends to the service-level chain.
| Sites | service, method |
|---|---|
| Args | one or more idents (or one array literal) |
@middlewares(RequestID, AuthRequired)
service UserService {
@middlewares(AdminOnly)
delete PurgeUser /users/{id} { ... } // chain: RequestID, AuthRequired, AdminOnly
}The named middleware must be declared via middleware Name; a middleware name is global to the design, and one from another package may also be written with its package, as in @middlewares(shared.RequestID, shared.CORS).
@tags(name1, name2, ...)
OpenAPI tags. Method-level appends to the service-level list; use @ignoreTags to drop the inherited list when a single method needs to opt out.
| Sites | service, method |
|---|---|
| Args | strings or idents (or one array literal) |
@tags(users, public)
service UserService {
@tags(admin)
delete PurgeUser /users/{id} { ... } // tags: [users, public, admin] (appended)
get GetUser /users/{id} { ... } // tags: [users, public]
}Method-level @tags(...) appends to the service-level list. Use @ignoreTags if a single method must drop the inherited list (see Service-level decorators and inheritance).
@security(A, B, ...)
OpenAPI security requirements. Each ident is a key from craftgo.design.yaml openapi.securitySchemes; when that map declares any scheme, the semantic check rejects unknown names. With no scheme declared, any name passes and the document declares it as an HTTP bearer (JWT) scheme.
| Sites | service, method |
|---|---|
| Args | variadic ident list (one or more scheme names) |
Within a single decorator the idents are AND-combined (the operation requires every listed scheme). Multiple @security(...) decorators on the same site OR-combine: any matching set unlocks the operation.
@security(bearer)
service UserService {
@security(oauth2)
post CreateUser /users { ... }
}IMPORTANT
@security is OpenAPI metadata only - it does NOT enforce anything at runtime. The decorator drives the security block in the generated OpenAPI spec so SDKs and Swagger UI know what the operation expects; no middleware is auto-attached, no header is auto-checked. Pair @security(Bearer) with an AuthRequired (or equivalent) middleware to actually verify the credential.
@ignoreSecurity on a method clears the inherited service-level @security chain - useful for a single public endpoint (liveness probe, etc.) inside an otherwise-protected service.
@hidden
Leave methods out of the OpenAPI document - an internal or operator endpoint that Swagger UI and the generated clients should not show. The route is still generated, registered and served; only the document changes.
| Sites | service, method |
|---|---|
| Args | none |
@prefix("/hidden")
service HiddenNotes {
get Read /note { response Note } // documented
@hidden
@security(InternalKey)
@errors(Sealed)
get Peek /peek { // served, not documented
request Stash
response Vault<Secret>
}
}
@hidden
extend service HiddenNotes { // every method of the block
post Rotate /rotate { request Secret response Note }
}
@hidden
@prefix("/hidden-ops")
service HiddenOps { // every method of the service
get Ping /ping { response Note }
}On a method it hides that method; on an extend service block, every method of the block; on a service, every method of the service, those of its extend service blocks included. No decorator brings a method of a hidden service back.
The schemas and security schemes only hidden methods use leave with them. A method uses each type, enum, scalar and error its request, response and @errors reach, through fields, mixins, map keys and generic arguments. Above, Secret, Stash, its enum Tier, VaultOfSecret, the SealedErr error and the InternalKey scheme are gone from the document, while Note, which Read answers, stays. A declared type no method uses stays, as it does without @hidden. A hidden method may share an OpenAPI path or an operationId with a documented one: the document holds only the documented one.
@hidden is a flag: @hidden() warns decorator/flag-empty-parens, and craftgo fmt drops the parentheses.
Service-level decorators and inheritance
Service-level decorators (@prefix, @group, @tags, @security, service-level @middlewares) declared on the primary service { ... } block apply to every method inside. Method-level decorators of the same kind append to the inherited chain:
@prefix("/v1")
@middlewares(AuthRequired)
@tags("users")
service UserService {
@doc("inherits AuthRequired + users tag")
get GetUser /{id} { ... }
@doc("inherits + adds AdminOnly + 'admin' tag")
@middlewares(AdminOnly)
@tags("admin")
delete PurgeUser /{id}/purge { ... }
}GetUser runs [AuthRequired] with tags ["users"]. PurgeUser runs [AuthRequired, AdminOnly] with tags ["users", "admin"] - the method-level decorators append, not replace.
Appending means a name stated at two levels would otherwise appear twice. Middlewares, tags, and security requirements are each deduplicated, keeping the first (outermost) occurrence: re-stating @middlewares(AuthRequired) on an extend block of an already-AuthRequired service leaves the chain [AuthRequired], not [AuthRequired, AuthRequired], so the middleware still runs exactly once per request.
@ignoreMiddleware / @ignoreSecurity / @ignoreTags
The append default fits the 90% case. For an exceptional method that must drop the inherited chain, use the matching @ignore* decorator at method level:
@middlewares(AuthRequired, RateLimit)
@security(Bearer)
@tags("internal")
service SecuredService {
get ListItems / { ... } // inherits AuthRequired + RateLimit + Bearer + internal tag
@doc("Liveness probe - public on purpose.")
@ignoreMiddleware
@ignoreSecurity
@ignoreTags
@tags("monitoring")
get Healthz /healthz { ... } // no middleware, no security, tag = ["monitoring"] only
}The combine semantic is clear-then-append:
@ignoreXon the method clears whatever the service contributed to that decorator's chain.- Any method-level
@X(...)decorators then append to the now-empty chain.
So @ignoreMiddleware + @middlewares(Audit) = method chain is exactly [Audit] (no inherited Auth). This is the reset-and-replace pattern - useful when one endpoint needs a completely different chain instead of the default.
The @ignore* decorators take no arguments. Repeating them is a decorator/duplicate error. On an extend service block they apply to every method of the block, as if each method wrote them: the primary service's chain is dropped, and the block's own @X(...) decorators start the chain afresh.
When the service is split across an extend service block, @ignore* clears the combined inherited chain - both decorators on the primary service { ... } declaration AND decorators on the extend service block. A method that opts out walks back to an empty chain regardless of which side of the split introduced the inheritance.
NOTE
No scheme name marks an endpoint public: @ignoreSecurity clears the inherited chain, the same way @ignoreMiddleware and @ignoreTags do.
extend service with decorators
The most common reason to split a service into extend service blocks is the 50/50 case: half the methods need one decorator chain (authenticated admin endpoints), half need another (public probes, sign-up, login). Putting the entire service under @middlewares(Auth) and using @ignoreMiddleware on every public method is verbose. The clean alternative is to declare the primary service with the public endpoints (no service-level decorators) and use an extend service block for the authenticated half:
service Users {
// Public endpoints
get Healthz /healthz { response HealthResp }
post Signup /signup { request SignupReq response User }
post Login /login { request LoginReq response Session }
}
@middlewares(AuthRequired)
@security(Bearer)
extend service Users {
get List /users { response UserList } // authenticated
get Get /users/{id} { request GetUserReq response User } // authenticated
post Create /users { request CreateUserReq response User } // authenticated
delete Del /users/{id} { request GetUserReq response OkResp } // authenticated
}Methods inside an extend block inherit the block's own decorators in addition to whatever the primary service declared. Multiple extend blocks may layer different decorator chains on the same service.
Rules for extend service decorators
- Only method-level-applicable decorators are valid on an
extend serviceblock - any decorator a method takes, such as@middlewares,@security,@tags,@timeoutor@errors, which every method of the block inherits (the repeatable@middlewares,@security,@tagsand@errorsappend to a method's own; a method that restates another one isdecorator/duplicate) - plus@group, which groups that block's own methods on disk.@prefixis primary-only and@operationIdnames a single operation, so either producesservice/extend-decorator-not-methodif put on extend. - The primary service declaration must exist in the same package. A cross-package extend produces
service/extend-orphan, related to the primary's declaration; with no primary anywhere the error isextend service "<Name>" has no primary declaration. To extend a service from another package, move the extend file into the primary's folder or rename the extend block to a new service.
Combinations cheatsheet
| Setup | Result | Notes |
|---|---|---|
@middlewares / @security / @tags on extend | yes | Method-level-applicable decorators on extend OK |
@prefix on extend | no | service/extend-decorator-not-method - move to primary |
@operationId on extend | no | service/extend-decorator-not-method - put it on each method |
@group on extend | yes | groups that block's methods under their own folder |
| Extend in a different folder (different package) | no | service/extend-orphan |
| Multiple extend blocks targeting the same service | yes | Each block's decorators apply only to its own methods |
@ignoreMiddleware on a method inside extend | yes | Clears extend-block + primary middleware chain |
@ignoreMiddleware on an extend block | yes | Clears the primary chain for each method of the block |
@hidden on an extend block | yes | Leaves each method of the block out of OpenAPI |
Method decorators
@ignoreMiddleware / @ignoreSecurity / @ignoreTags
Opt the current method out of the inherited service-level chain for the matching decorator. Useful for public endpoints (probes, sign-up, login) inside an otherwise-authenticated service, or for an admin method that needs a completely different middleware chain than the service default.
| Sites | method |
|---|---|
| Args | none |
@middlewares(AuthRequired)
@security(Bearer)
@tags("internal")
service Users {
get List /users { response UserList } // inherits all three
@ignoreSecurity // clears Bearer only
get Avatar /users/{id}/avatar { request GetUserReq response Avatar }
@ignoreMiddleware // clears AuthRequired
@middlewares(BasicAuth, Audit) // method-level chain becomes [BasicAuth, Audit]
post Reset /admin/reset { request ResetReq response OkResp }
}The combine semantic is clear-then-append: @ignoreX clears the inherited chain first, then any method-level @X(...) decorators append to the now-empty chain. An operation whose tag chain ends up empty is tagged with its service's name. See Service-level decorators and inheritance.
@summary(text)
OpenAPI operation summary (one-line).
| Sites | method |
|---|---|
| Args | (string) |
@operationId(name)
Override the auto-generated operationId in OpenAPI.
| Sites | method |
|---|---|
| Args | (string) |
@operationId("getUserById")
get GetUser /users/{id} { ... }@status(code)
Override the success status code. By default it is chosen from the verb and response:
| Method | Default success status |
|---|---|
POST returning a body | 201 Created |
GET / PUT / PATCH / DELETE returning a body | 200 OK |
| any method with no response body | 204 No Content |
@status(code) overrides that default - for example a POST that updates rather than creates:
| Sites | method |
|---|---|
| Args | (int) |
@status(200)
post SearchUsers /users/search { ... }The generated handler writes the code with w.WriteHeader (only when it differs from the implicit 200), and the OpenAPI spec lists the same code as the success response - the two never drift.
@errors(E1, E2, ...)
Declare which error types this method may return. Drives OpenAPI's per-status response entries.
| Sites | method |
|---|---|
| Args | error type idents (or one array literal) |
@errors(UserNotFound, EmailTaken)
post CreateUser /users { ... }Content negotiation - @consumes / @produces / @accepts
Not supported. craftgo's transport hardcodes application/json for both request decode and response encode (a request holding a file is multipart/form-data), so a content-negotiation decorator would parse but produce no runtime or spec effect - which hides the JSON-only constraint from authors. The decorator surface stays small and honest: the transport pipeline is JSON in, JSON out, and the codec is swappable wholesale via server.SetGlobalJSONCodec when a project wants sonic or goccy/go-json in place of encoding/json, given a codec that honours omitzero (see Runtime API). When one side needs another wire format, hand that side to logic with @rawRequest / @rawResponse below - logic then owns the bytes, and the block on that side stays the documented contract.
Raw sides - @rawRequest / @rawResponse / @passthrough
Every method has two transport sides. By default the framework owns both: it binds and validates the request, and JSON-encodes the response. The raw-mode flags hand one side (or both) to your logic while the request / response blocks stay the documented contract - they still shape the OpenAPI document and the generated Go types, but the transport never touches a block on a raw side.
| Decorator | Request side | Response side | Service stub signature |
|---|---|---|---|
| (none) | bound + validated | JSON-encoded | (req *types.Req) (*types.Resp, error) |
@rawResponse | bound + validated | logic writes w | (w http.ResponseWriter, r *http.Request, req *types.Req) error |
@rawRequest | logic reads r | JSON-encoded | (r *http.Request) (*types.Resp, error) |
@passthrough | logic reads r | logic writes w | (w http.ResponseWriter, r *http.Request) error |
Drop the req parameter or the *types.Resp result when the method declares no block on that side. @passthrough is exactly @rawRequest @rawResponse; spelling a raw side twice (@passthrough next to a flag, or both flags together) is accepted with a decorator/redundant warning and generates identical code.
| Sites | method |
|---|---|
| Args | none (flag form) |
@rawResponse - typed request, raw response
The headline use: a body that already exists as bytes (a cache entry stored gzip / zstd compressed, a file, a stream) goes to the client without an encode step, while the client-facing contract stays typed.
@rawResponse
get Snapshot /snapshot {
request SnapshotReq
response Snapshot
}The generated handler binds and validates SnapshotReq exactly as for a typed method, then hands the writer over. The stub you implement:
func (l *SnapshotService) Snapshot(w http.ResponseWriter, r *http.Request, req *types.SnapshotReq) error {
region := "global"
if req.Region != nil {
region = *req.Region
}
blob, ok := snapshotCache[region] // stored already gzip-compressed
if !ok {
http.NotFound(w, r)
return nil
}
return server.WritePrecompressed(w, r, http.StatusOK, "application/json; charset=utf-8", "gzip", blob, gunzip)
}server.WritePrecompressed negotiates against Accept-Encoding: a client that accepts the stored coding receives the bytes verbatim with Content-Encoding set, any other client receives the decoded form (see Runtime API). The OpenAPI operation still documents a typed body:
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/SnapshotRespBody'
description: OKOn a raw response side @status(N) and the response type's @header / @cookie fields are documentation only - logic writes the real status and headers. Returning an error still goes through server.WriteError, unless the response was already committed (then the error is logged and the wire is left alone).
@rawRequest - raw request, typed response
Logic receives the *http.Request unread - no body decode, no wire binding, no Validate() - and returns the typed response the framework encodes:
@rawRequest
post Ingest /ingest {
response IngestResult
}func (l *IngestService) Ingest(r *http.Request) (*types.IngestResult, error) {
n, err := io.Copy(io.Discard, r.Body)
if err != nil {
return nil, err
}
return &types.IngestResult{Bytes: int(n)}, nil
}A request block on a raw request side documents the contract (JSON body, multipart/form-data when it has a file field, path / query parameters) and generates the type - decode into it and call its Validate() yourself when that helps. The verb-aware status default (201 for a POST with a body) still applies, because the framework writes the response. A route with {param} segments and no request block skips the path/param-missing error: read the value with r.PathValue.
@passthrough - both sides raw
@passthrough
get Events /events {
response Event
}Logic gets (w, r) and owns the whole wire; the optional blocks are the documented contract (here: each SSE data: frame carries one Event). Without blocks the operation is documented as */* with bare string path parameters. @timeout applies to raw and passthrough routes like any other route: it only cancels the request context, so a streaming loop that selects on ctx.Done() stops cleanly and nothing is cut off on the wire.
@timeout(duration)
Cap the handler's execution time. Overrides the global server.handlerTimeout config — the per-method value is used as-is (it may be shorter or longer than the global default); routes without @timeout inherit the global default. When the deadline elapses the framework cancels the request context, so a handler that checks ctx.Done() can return early. Nothing is written when the deadline passes (unlike the deprecated server.Timeout() middleware, which wraps http.TimeoutHandler and answers 503); a handler that then returns the context's error, or an error wrapping it, gets 504 {"message":"gateway timeout"} from server.WriteError. The deadline applies to @rawResponse / @passthrough routes as well - a streaming handler should select on ctx.Done().
| Sites | method |
|---|---|
| Args | (duration) like 5s, 100ms, 2m |
@timeout(5s)
post ProcessImage /images/process { ... }@maxBodySize(size)
Cap the request body size in bytes; the value replaces the global server.maxBodySize for this route. Both enforcement points answer 413 {"message":"request entity too large"}:
- Pre-check on
Content-Length- when the client declares a length bigger than the cap, the middleware answers 413 immediately without touching the body. Catches oversized requests even when downstream validation would short-circuit before reading. http.MaxBytesReaderwrapsr.Body- a read past the cap (a chunked body, or one longer than it declared) answers the same 413:server.WriteValidationErroranswers a*http.MaxBytesErrorwith 413 without calling the validation hook. A multipart or form body the cap cuts mid-part, which the parser reports as a malformed part, answers 413 too.
For multipart uploads, @maxBodySize also lifts the in-memory parser budget above the stdlib's 32 MiB floor so files up to the declared cap stay in memory without spilling to a temp file.
| Sites | method |
|---|---|
| Args | (size) like 1MB, 8KB, 100 |
@maxBodySize(1MB)
post UploadAvatar /users/{id}/avatar { ... }Conflict matrix
Some decorator combinations are rejected (or flagged) by the semantic analyzer:
| Decorator | Conflicts with | Why |
|---|---|---|
@sensitive | All validators, all bindings, @nullable, @default | Field never crosses the wire - constraints meaningless |
@nullable | @default | A member sent as null takes the default, so it never reads as null |
@passthrough | @rawRequest, @rawResponse (and the two flags together) | Redundant, not rejected: decorator/redundant warning, identical output - write one spelling |
Wrong-site placement (@prefix on a field, @length on a number) fires decorator/placement or decorator/typemismatch.
See also
- Decorator Registry - the full lookup table (levels, args, effect)
- Validators for runtime semantics
- Middleware for
@middlewaresand middleware declaration - Errors for
@errorsand the category-to-status mapping - llms.md for a single-page consolidated dump