Skip to content

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).

craftgo
@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 forcraftgo usesWhy
@min(n) / @max(n)@gte(n) / @lte(n) (inclusive), @gt / @lt (strict)One spelling for inclusive vs strict, consistent with @range(min, max).
@requirednothing - fields are required by defaultA field is required unless its type carries ?. Use ? to opt out.
title / externalDocs on an operationcraftgo.design.yaml openapi.title; @doc("… https://…")Document-level metadata lives in the manifest, not per-decorator.

Sites ​

Site nameWhere the decorator sits
fileAbove the package line at the top of a .craftgo file
typeAbove a type declaration
fieldAbove a field in a type body, or after it on its line
enumAbove an enum declaration
enumValueAbove an enum value, or after it on its line (Active @doc("..."))
errorAbove an error declaration
errorFieldAbove a field in an error body, or after it on its line
scalarAbove a scalar declaration, or after it on its line
serviceAbove a service declaration
methodAbove an HTTP method inside a service body
middlewareAbove a middleware declaration
eventAbove 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:

craftgo
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.

Sitesfile, type, field, service, method, enum, enumValue, error, errorField, scalar, middleware, event
Args(string)
craftgo
@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:

craftgo
// 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.

Sitesfile, type, field, service, method, enumValue, errorField, middleware, event
Args() or (string)
craftgo
@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.

Sitesfield, errorField
Argsa literal (string / int / float / bool / null) or an array of those
craftgo
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.

Sitesfile
Args(string)
craftgo
@version("2.0.0")
package design

Cross-field type rules ​

@requiresOneOf(field1, field2, ...) ​

At least one of the listed fields must be present.

Sitestype
Argsone or more idents (or one array literal)
craftgo
@requiresOneOf(email, phone)
type Contact {
    email string?
    phone string?
}

@mutuallyExclusive(field1, field2, ...) ​

At most one of the listed fields may be present.

Sitestype
Argsone or more idents (or one array literal)
craftgo
@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 @required decorator - to mark a field optional, write name string?. To allow null while keeping the field mandatory, add @nullable. To fill a value left out or sent as null, add @default(...) (which also auto-marks the field optional on save).

Error-body validators are spec-only. Validators (@minLength, @pattern, @range, ...) on error body 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.go does generate a Validate() on the error's <Name>Body, which your handler may call before New<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).

DecoratorArgsEffect
@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.

craftgo
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.

DecoratorArgsEffect
@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
craftgo
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.

DecoratorArgsEffect
@minItems(n)(int)At least n elements
@maxItems(n)(int)At most n elements
@uniqueItems()All elements distinct (arrays only)
craftgo
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).

DecoratorArgsEffect
@maxSize(bytes)(size)Cap upload size, enforced while the part is read. Accepts 2MB, 8KB, etc.
@mimeTypes([...])string arrayAllowed media types or type/* ranges
craftgo
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.

Sitesfield, errorField
Args()
craftgo
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.

Sitesfield, 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.

craftgo
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 -.

Sitesfield, errorField
Args(string)
craftgo
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.

Sitesfield, errorField
Args()

Conflicts: cannot combine with any wire-shaping decorator (validators, bindings, @json, @nullable, @default).

craftgo
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).

DecoratorSitesReads from
@bodyfieldRequest body (JSON or form)
@pathfieldURL path parameter {name}
@queryfieldURL query string
@headerfield, errorFieldRequest header (input) / response header (error fields)
@cookiefield, errorFieldRequest cookie (input) / response cookie (error fields)
@formfieldMultipart 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:

craftgo
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:

go
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:

  • body for body verbs (POST / PUT / PATCH)
  • query for 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:

craftgo
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.

AttributeValueLeft out
patha path starting with /"/", every path of the site
domaina host name, a leading . allowednone: the host that set it alone
maxAgea duration of whole seconds (24h), or an int of secondsnone: the cookie lasts the browser session
httpOnlytrue or falsetrue: scripts cannot read it
securetrue or falsetrue: sent over HTTPS only; browsers treat http://localhost as secure
sameSitelax, strict or none (which needs secure)lax
craftgo
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:

go
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.

Sitesservice
Args(string)
craftgo
@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.

Sitesservice, extend service
Args(string)
craftgo
@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 @group land in the same directory on purpose - that is how you gather a versioned or feature surface (@group("shared/v1") on both Alpha and Beta) into one folder. Their handlers and stubs are per-method files that sit side by side, and the directory gets one routes.go registering every contributor's methods, which the umbrella routes.go calls once. Sharing works the same way when a @group names an ungrouped service's own directory (@group("beta") alongside service 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 Go package declaration 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>.go and 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:

craftgo
@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.

Sitesservice, method
Argsone or more idents (or one array literal)
craftgo
@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.

Sitesservice, method
Argsstrings or idents (or one array literal)
craftgo
@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.

Sitesservice, method
Argsvariadic 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.

craftgo
@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.

Sitesservice, method
Argsnone
craftgo
@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:

craftgo
@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:

craftgo
@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:

  1. @ignoreX on the method clears whatever the service contributed to that decorator's chain.
  2. 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:

craftgo
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 service block - any decorator a method takes, such as @middlewares, @security, @tags, @timeout or @errors, which every method of the block inherits (the repeatable @middlewares, @security, @tags and @errors append to a method's own; a method that restates another one is decorator/duplicate) - plus @group, which groups that block's own methods on disk. @prefix is primary-only and @operationId names a single operation, so either produces service/extend-decorator-not-method if 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 is extend 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 ​

SetupResultNotes
@middlewares / @security / @tags on extendyesMethod-level-applicable decorators on extend OK
@prefix on extendnoservice/extend-decorator-not-method - move to primary
@operationId on extendnoservice/extend-decorator-not-method - put it on each method
@group on extendyesgroups that block's methods under their own folder
Extend in a different folder (different package)noservice/extend-orphan
Multiple extend blocks targeting the same serviceyesEach block's decorators apply only to its own methods
@ignoreMiddleware on a method inside extendyesClears extend-block + primary middleware chain
@ignoreMiddleware on an extend blockyesClears the primary chain for each method of the block
@hidden on an extend blockyesLeaves 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.

Sitesmethod
Argsnone
craftgo
@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).

Sitesmethod
Args(string)

@operationId(name) ​

Override the auto-generated operationId in OpenAPI.

Sitesmethod
Args(string)
craftgo
@operationId("getUserById")
get GetUser /users/{id} { ... }

@status(code) ​

Override the success status code. By default it is chosen from the verb and response:

MethodDefault success status
POST returning a body201 Created
GET / PUT / PATCH / DELETE returning a body200 OK
any method with no response body204 No Content

@status(code) overrides that default - for example a POST that updates rather than creates:

Sitesmethod
Args(int)
craftgo
@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.

Sitesmethod
Argserror type idents (or one array literal)
craftgo
@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.

DecoratorRequest sideResponse sideService stub signature
(none)bound + validatedJSON-encoded(req *types.Req) (*types.Resp, error)
@rawResponsebound + validatedlogic writes w(w http.ResponseWriter, r *http.Request, req *types.Req) error
@rawRequestlogic reads rJSON-encoded(r *http.Request) (*types.Resp, error)
@passthroughlogic reads rlogic 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.

Sitesmethod
Argsnone (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.

craftgo
@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:

go
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:

yaml
      responses:
        "200":
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SnapshotRespBody'
          description: OK

On 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:

craftgo
@rawRequest
post Ingest /ingest {
	response IngestResult
}
go
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 ​

craftgo
@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().

Sitesmethod
Args(duration) like 5s, 100ms, 2m
craftgo
@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"}:

  1. 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.
  2. http.MaxBytesReader wraps r.Body - a read past the cap (a chunked body, or one longer than it declared) answers the same 413: server.WriteValidationError answers a *http.MaxBytesError with 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.

Sitesmethod
Args(size) like 1MB, 8KB, 100
craftgo
@maxBodySize(1MB)
post UploadAvatar /users/{id}/avatar { ... }

Conflict matrix ​

Some decorator combinations are rejected (or flagged) by the semantic analyzer:

DecoratorConflicts withWhy
@sensitiveAll validators, all bindings, @nullable, @defaultField never crosses the wire - constraints meaningless
@nullable@defaultA 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 ​

Released under the MIT License.