| title | Code Generation |
|---|---|
| weight | 3 |
| description | Complete guide to Loom's code generation - commands, process, generated code structure, and customization options. |
| llm_optimized | true |
| aliases |
Loom's code generation transforms your design into production-ready service
interfaces, endpoints, transport adapters, clients, and API contracts. The
loom example command creates runnable starter files. Each service stub
returns a Loom fault until the application replaces it.
go install github.com/CaliLuke/loom/cmd/loom@v1.10.0-alpha.5loom version reports the installed Go module version. A local checkout or
local module replacement reports (devel). The generation manifest records
the Loom dependency used to compile the generator, which may differ from the
installed CLI launcher. Prefer pinning both to the same release.
Programmatic callers should use pkg.Version() instead of the removed Minor,
Build, and Suffix release constants. Major remains the Go module API
namespace.
Applications that want reproducible generation can record the Loom command as a Go tool dependency:
go get -tool github.com/CaliLuke/loom/cmd/loom@v1.10.0-alpha.5
go tool loom gen example.com/myservice/designThis records the command's complete dependency graph in the application's
go.mod and go.sum. Installing only the root Loom module does not necessarily
record command-only dependencies such as the code generator's source-emission
packages.
The gen, example, and vet helpers use the caller's active Go workspace,
including its use modules and replace directives. An explicit GOWORK
selection and automatic go.work discovery follow Go's normal rules. With
GOWORK=off, helpers use the design module's go.mod replacements.
loom gen intentionally regenerates the complete gen/ tree. Repeating it
with the same design and output path is byte-stable. Loom orders implicit
service collections by service name, so Go's dependency-driven package
initialization order cannot move unrelated services through aggregate clients,
CLIs, or transport contracts. An explicit Server(... Services(...)) list
keeps the order authored by the application.
HTTP and JSON-RPC example clients pass optional dependencies in the generated
ParseEndpoint signature's order: a shared WebSocket dialer and each streaming
service's configuration, then each hosted service's multipart encoders followed
by its client interceptors. Services without transport endpoints contribute no
arguments. This fixes example clients that combine multipart and interceptors
across services; the parser signature is unchanged. loom example does not
replace existing files. For an affected application, compare a fresh example in
a separate directory and reorder the arguments in its existing call.
For a server exposing both HTTP and JSON-RPC, the example client selects the
transport from the service and endpoint command names. JSON-RPC commands work
without a transport flag, including JSON-RPC methods on a service that also has
HTTP methods. -j or -jsonrpc explicitly selects JSON-RPC; a command available
through both transports defaults to HTTP. To adopt this correction, regenerate
gen/, compare a fresh example in a separate directory, and update the existing
CLI's transport selection and command lookup bindings. loom example does not
overwrite existing CLI files.
Generated Go names belong to their declaration scope. Service-package types
avoid emitted framework declarations such as Client, NewEndpoints, and
method stream or body carriers by receiving a deterministic numeric suffix.
Built-in error factories also occupy this namespace: distinct wire error names
receive distinct factory names even when both prefer the same Make spelling.
Conditional names such as Authorizer are reserved only when that feature emits
the declaration. A type moved to another package keeps its canonical name.
Service methods use a separate member namespace: a domain type Foo does not
rename method foo, and methods named Client or NewClient remain valid.
Actual member conflicts, such as Use on the endpoint aggregate, still receive
a suffix. Refer to generated declarations rather than predicting a suffix when
multiple names compete.
Regenerate clients and servers together after this naming correction and update
handwritten references to renamed Go types or methods. If a domain type formerly
forced method foo to become FooEndpoint, the method is now Foo. gRPC derives
its RPC name from that service method identity, so its RPC name and full method
path also change from FooEndpoint to Foo; update independently generated or
handwritten gRPC clients accordingly. HTTP routes, JSON-RPC method strings,
OpenAPI schema names, and protobuf field numbers retain their design meanings.
HTTP and JSON-RPC transport client factories, request builders, Doer fields and
mixed-result stream factories share the concrete Client namespace. Fixed members
such as RestoreResponseBody, JSON-RPC Doer, and WebSocket Close take priority;
conflicting generated roles receive deterministic suffixes. HTTP operation groups
use those same recorded names. Regenerate and update handwritten references to
changed transport members using their declarations. Service method names, CLI
commands and wire method names retain their design identities.
The service client applies the same receiver rule to mixed results: if a watch
method's synthetic WatchStream call conflicts with an authored watchStream
method, the synthetic call gets a suffix. The authored WatchStream call keeps
its identity. Regenerate and use the client declarations when wiring both forms.
Native gRPC server handler fields also have a separate receiver namespace.
Legal RPC pairs such as mount and mountH keep their service and protobuf
names; a colliding generated handler field receives a deterministic suffix.
An RPC named like the protobuf unimplemented-server embedding remains valid.
Regenerate the server and update handwritten references to changed handler fields;
protobuf clients and wire method names are unchanged by this correction.
HTTP and JSON-RPC conversion constructors also share their generated package's
namespace. Constructors with the same target type can have different names when
they accept different sources or select different fields. Identical conversion
plans may share a constructor. Regenerate clients and servers together and update
handwritten calls to renamed constructors; use generated declarations rather than
assuming New plus the target type identifies a conversion. This allocation does
not change authored routes, RPC method strings, body types or OpenAPI schemas.
Plugin files outside gen/ can set codegen.File.SkipExist to keep existing
user-owned files. Create-only artifacts inside gen/ are rejected: that tree
is owned by regeneration and is always replaced. Put user-editable plugin files
outside gen/. All case variants of the top-level gen name are reserved.
Stable parent-directory aliases into that tree are rejected too, including
aliases whose descendants do not exist yet. This policy applies to the live
output directory even when loom gen renders into a staging directory. An
existing file, directory, or symlink is preserved; an absent destination is
created without replacing a concurrent creator. Skipped files are omitted from
the command's output paths.
Generators contributing to one output path must agree on SkipExist; conflicting
policies fail before rendering.
Programmatic generator.Generate callers receive []codegen.Artifact with
Path and SkipExist fields. Use artifact.Path when only a rendered path is
needed. A caller installing staged files must retain and enforce SkipExist at
the live destination. The CLI launcher and design module dependency should be
updated together because their private helper protocol now carries these
artifact records. The public CLI still prints one installed path per line.
All artifact paths must name local files below the output directory. Distinct
paths that collide under case folding, or make one file the directory ancestor
of another, fail before rendering. Exact same-path contributions still merge in
order. For gen, gen/loom.json and its case variants are reserved for Loom's
manifest. Removal paths must also stay below the output directory.
generator.Generate accepts optional resource settings:
artifacts, err := generator.Generate(output, "gen", designPackage, debug,
generator.WithRenderWorkers(2))The default is at most four simultaneous file renders, further limited by
GOMAXPROCS. An override must be positive; repeated overrides use the last value.
The setting affects rendering only. CLI generation uses the default. A rendering
failure stops new dispatch, waits for started renders, and reports the earliest
failing artifact in plan order. Independent per-file effects and stable
filesystem state are required for results to be independent of worker count.
Custom sections and finalizers must finish normally; Loom cannot interrupt an
arbitrary callback. CLI staging still prevents installation of a failed run.
Generated-file plugins retain their existing whole-collection rewrite phase. That phase, shared analysis and the largest individual file can still determine peak memory; limiting render workers is not a total memory limit. Debug output includes cumulative allocation and GC counters plus current allocated/reserved heap. Allocated heap includes uncollected garbage and is not a reachable-heap or RSS measurement. Normal generation does not force garbage collection.
For an existing //go:generate or script that intentionally uses go run, add
the command package—not only the root module—before invoking it:
go get github.com/CaliLuke/loom/cmd/loom@v1.10.0-alpha.5
go run github.com/CaliLuke/loom/cmd/loom gen example.com/myservice/designGeneration commands expect Go package import paths, not filesystem paths:
loom gen github.com/CaliLuke/loom-examples/calc/designloom import openapi <input.json-or-yaml> [-o <design.go-or-directory>] [--allow-lossy] [FILTERS]Run loom import openapi --help for the importer-specific flag reference.
Use operation filters to import one service boundary from a large contract:
loom import openapi monolith.json --tag "Face capture" --tag Videoselfie -o design/face.go
loom import openapi monolith.json --path-prefix /omni/b2b/v1 -o design/b2b.go
loom import openapi monolith.json --path "/omni/*/device-*" -o design/device.go
loom import openapi monolith.json --list-tagsRepeat a filter to add selections. Different filter types also form a union.
Tag matches are exact. Path patterns use Go path.Match syntax, where *
does not match /.
The importer retains the transitive component closure for the selected operations. An unrelated component does not appear in the generated design or its refusal set.
When a tag filter is active, the command reports each unclaimed path on
standard error. --list-tags reports deterministic operation and path counts
without writing a design.
This command creates one gofmt-formatted Loom design from the strict supported
subset of an OpenAPI 3.0, 3.1, or 3.2 contract. OpenAPI 3.0 inputs are
translated to the equivalent OpenAPI 3.1 design metadata. The default output is
design/design.go. An existing directory, a path ending in a separator, or a
non-existing extensionless path is treated as a directory; a .go path names
the output file directly.
See OpenAPI Import Coverage for the field and schema-keyword matrix. The matrix distinguishes preserved, conditional, lossy, and rejected constructs.
Import is intentionally lossless-or-fail by default: unsupported constructs are
reported together and no partial design or TODO placeholders are written. The
command also refuses to overwrite an existing target. Review the imported
design, then run loom gen <module-import-path>/design normally.
Use --report to print grouped blocker counts and affected operations without
writing a design. Use --skip-unrenderable to write all renderable operations
and report each skipped operation. Partial import also omits unsupported
document-level members such as servers and unsupported info metadata. It
reports these under skipped (document level) without making
otherwise-renderable operations fail. Operation-level servers still make that
operation unrenderable.
API-key, HTTP basic, HTTP bearer, and supported OAuth 2.0 schemes import
without loss. OAuth 2.0 flows must use the same scope map because Loom defines
scopes on the scheme. The importer preserves root and operation requirement
alternatives, including OAuth scopes, AND requirements, an anonymous {}
alternative, and an explicit operation security: [] override. Generated designs use
Security() with no scheme for the anonymous alternative and NoSecurity()
for the explicit empty override. Unsupported security scheme kinds, references,
locations, bearer formats, and OAuth flow shapes remain strict diagnostics. The
importer never silently weakens an authentication contract.
Both modes use these exit codes:
| Code | Result |
|---|---|
0 |
All selected operations are importable. |
3 |
Some selected operations are importable; partial output is available. |
2 |
No selected operation is importable; no design is written. |
1 |
Usage, input, output, or another command failure occurred. |
--report and --skip-unrenderable work with operation filters and
--allow-lossy. Report mode never creates the output path.
The importer converts each operationId to an idiomatic Go method name. It
uses matching path words to split lowercase IDs and preserves initialisms such
as B2B.
Path parameter attributes keep their authored names so they remain identical
to route placeholders. For example, {asset_id} renders as an asset_id
payload attribute and Param("asset_id"); generated Go still uses the
idiomatic AssetID field name.
The importer maps multipart/form-data request bodies to
MultipartRequest(). It maps application/x-www-form-urlencoded request
bodies to FormRequest(). Both request body schemas must define an object.
The importer maps type: string schemas with format: byte or
format: binary to Bytes. Regeneration preserves the selected format.
If one request body lists multiple media types, each entry must use the same schema and examples. Loom imports supported entries as one documented raw stream. The generated service must inspect the content type and decode the stream. A form or multipart entry requires a shared object schema. Different schemas, examples, per-part encodings, and unsupported media types remain strict import errors.
OpenAPI 3.0 nullable: true and OpenAPI 3.1/3.2 two-member type unions such as
type: [string, "null"] import as Nullable() and generate
loom.Nullable[T]. Authored Nullable() attributes use the same representation.
Its zero value means the property was absent; loom.NullValue[T]() means it
was explicitly null; and loom.NullableValue(value) carries a concrete value.
Use Present, IsNull, and Value when handling generated service types.
A two-member oneOf with one bare null branch and one local component
reference imports as the same nullable named type when the referenced schema
explicitly excludes null. Regenerated OpenAPI uses the equivalent anyOf
representation. A oneOf of named object or array components in a JSON request
or success response imports as an untagged typed union. Recursive contents may
be primitives, named objects, and arrays of these shapes. Inline object branches
become deterministic named components; inline array branches remain unsupported.
Generated Go retains sum-type constructors and accessors. Encoding and decoding
require the same unique schema and Go branch. Inline nested objects, maps,
nested unions, scalar unions, discriminated source unions, declared error unions,
and untagged unions in string-encoded transport locations remain strict import errors.
A scalar JSON Schema const without a sibling enum imports as an equivalent
one-member enum. Regenerated OpenAPI emits enum. Structured constants and
schemas that combine const with enum remain strict import errors.
An unconstrained schema {} imports as Loom Any. This rule applies to
components, object properties, array items, request bodies, and responses. The
generated OpenAPI preserves {}, including a named component identity.
Generated Go uses loom.JSONValue for a direct Any,
map[string]loom.JSONValue for MapOf(String, Any). An explicitly nullable
field uses loom.Nullable[loom.JSONValue] to preserve absence separately from
JSON null.
loom.JSONValue is an alias for jsontext.Value. It keeps the source
bytes and does not convert JSON numbers to float64.
Decode a stored JSON document directly into the generated result type. This
operation forwards each Any value without a response-writer interceptor:
var result widgets.ShowResult
if err := json.Unmarshal(stored, &result); err != nil {
return nil, err
}
return &result, nilTo construct a direct generated field from raw JSON, convert one complete,
valid JSON value to loom.JSONValue:
result.ViewProps = loom.JSONValue(rawValue)Use loom.JSONValueFromString(value) to encode a Go string as a JSON
string. Use loom.JSONValueFrom(value) to encode another Go value. If the
design explicitly declares the field nullable, wrap the JSON value with
loom.NullableValue(...).
A schema that has constraints but no type remains a strict import error.
The equivalent anyOf: [{}, {type: "null"}] form normalizes to Any because
the empty schema already accepts null.
A free-form object with type: object and additionalProperties: true imports
as MapOf(String, Any). Generated Go uses
map[string]loom.JSONValue. Regenerated OpenAPI preserves the object type and
additionalProperties: true.
A form body with only schema-valued additionalProperties imports as a typed
map. Body-only methods keep the map as the service payload; optional bodies add
OptionalRequestBody(). When path, query, header, cookie, or security fields
must coexist with the form map, the importer creates an object payload, places
the map in a collision-free attribute, and selects it with Body(...) plus
FormRequest(). A contract that lists JSON, form, and multipart encodings for
the same map stays a raw request stream so the service owns content negotiation.
Optional multipart maps use the same raw contract.
Form schemas that contain unconstrained values also stay raw because typed form
decoding cannot reconstruct JSON-valued fields or map entries.
An optional form object with required members also remains raw; this preserves
the distinction between an absent body and a present body that must satisfy its
required fields.
Generated HTTP clients currently reserve the local name body. Until local
names are independently scoped, an imported operation that combines any
request body with a transport field that Goifies to body fails closed instead
of emitting uncompilable code.
An object cannot combine declared members with additionalProperties: true.
The Loom DSL cannot preserve both parts of that contract, so import fails.
JSON-compatible x-* extensions are preserved at document, operation, schema,
parameter, request-body, and response scopes, and regenerated OpenAPI emits each
one at the scope it was imported from. A scope that shares one Loom attribute
with another scope carries a scoped metadata key, such as
openapi:parameter:extension:<x-name> beside openapi:schema:extension:<x-name>
on the same payload attribute. Generation also accepts
openapi:mediaType:extension:<x-name> and openapi:header:extension:<x-name>
for the media type and response header objects.
Extensions at unsupported scopes are never discarded silently. Strict import
fails before writing output and names each one. With --allow-lossy, the
importer omits them and reports one deterministic warning per extension.
A single-member allOf containing a local schema reference imports losslessly
as that reference. Numeric bounds and compatible scalar defaults remain
attached to the occurrence, and regenerated OpenAPI emits the canonical
reference in allOf with those sibling constraints. Other contract siblings
remain unsupported. With --allow-lossy, the importer also supports the common
Spring inheritance shape allOf: [$ref, inline object]. It renders the parent
with Extend(...) and keeps the inline properties and required fields.
Regenerated OpenAPI flattens this inheritance relationship. When such an
inline object is used directly as array items, the importer promotes it to a
deterministically named component so Loom can render the array. The promotion
is reported as a lossy warning because it changes the schema's component
structure without changing its fields or validation. Other allOf shapes
remain blocked. The importer also blocks unsupported oneOf, anyOf, and
not shapes.
It rejects a Schema Object with $ref siblings because returning only the
reference would discard the sibling constraints. Wrap the reference in a
supported allOf shape instead.
OpenAPI 3.2 Media Type description requires --allow-lossy because the Loom
HTTP DSL has no media-level description. prefixEncoding remains a strict
error.
An operation must define exactly one primary successful response. The importer uses its single 2xx response when present. If no 2xx response exists, it uses a single 3xx response instead, so redirect-only operations import without inventing a 2xx response. Other responses remain method errors.
For a single non-JSON success response, the importer keeps the media type and
schema as an OpenAPI-only body. It uses FileResponse() for a compatible
GET or HEAD response with status 200. It uses
SkipResponseBodyEncodeDecode() for other methods and statuses. The service
implements the streamed response body.
The generated method keeps the original wire value in
Meta("openapi:operationId", ...). Regenerated OpenAPI documents therefore
retain the source operationId.
The importer preserves schema title, example, examples, default,
deprecated, readOnly, and writeOnly without a flag. It also preserves
unformatted integer and number schemas. The int32, int64, float, and
double formats are also supported.
A schema-less error response stays bodyless. The importer does not add response headers that are absent from the source contract.
The importer maps these members to Title(...), Example(...), Default(...),
metadata, Int, or Float64. It maps request and response media examples to
Example(...). This support includes structured examples and reusable component
examples. It reports external or incompatible examples at their source locations.
The importer preserves tag metadata and response summaries. It also preserves
the supported parameter and header serialization that Loom emits. Parameter
support includes the default styles, cookie style, and allowReserved. Header
support includes simple style and allowReserved. Other custom serialization
remains a strict diagnostic.
The document version controls these mappings. The importer rejects a 3.2-only field in a document that declares OpenAPI 3.0 or 3.1.
Use --allow-lossy only when you explicitly accept omission of non-contract
metadata or of constructs the Loom HTTP DSL cannot express per-parameter or
per-header. It writes the design and reports deterministic warnings to stderr
for: info metadata, external documentation, path metadata, media type
descriptions, and unrenderable or parameter/header
examples. It also reports
unrecognized format values and renders them without a format validation.
OpenAPI 3.1 specifies that an unknown format must not stop processing. The flag
also omits parameter-level or header-level deprecated. The HTTP DSL has no
deprecated marker for these items. The importer always preserves a schema's
own deprecated keyword. It never
downgrades contract-affecting diagnostics such as servers, security,
extensions, callbacks, links, custom serialization, media encodings, or
unsupported schema composition and structural keywords. The documented
allOf flattening and inline array-item promotion are explicit lossy
exceptions. Without --skip-unrenderable, other diagnostics prevent output.
With that flag, the importer omits each affected
operation. The document-level omissions use the separate behavior above.
loom gen <design-package-import-path> [-o <output-dir>] [--debug]The primary command for code generation:
- Processes your design package and generates implementation code
- Generates and finalizes a complete staging tree, validates its manifest and
outputs, then replaces the entire
gen/directory and any declared plugin outputs on success. A generation, finalization, validation, or installation failure attempts to restore the previous generated artifacts; restoration errors are reported rather than treated as successful rollback. Originals that cannot be restored remain in their backup locations; the error names each backup and its intended destination for manual recovery. Automatic cleanup preserves these originals. - Run after every design change
The output directory must be writable; its parent need not be. Loom stages
inside the output directory and prepares plugin files beside their destinations,
so the module, nested output directory, and plugin destinations may reside on
different filesystems. Relative local module replacements retain their original
targets, and the caller's module and workspace configuration are not rewritten
for staging. Mount the output directory rather than gen/ itself: regeneration
replaces the whole gen/ directory, and a mount directly on it is rejected while
preserving its contents.
loom vet <design-package-import-path> [--format text|json|sarif] [--debug]The command is available in v1.9.0-alpha.1 and later releases.
loom vet evaluates the design before analyzing it. This preserves
validations, errors, and fields composed through helper functions. It also
inspects the Go module that contains the design.
Run this command periodically to audit API modeling. It is not part of the normal generation workflow or a routine local gate.
The manifest identifies the originating design package and fingerprints all registered evaluated DSL roots, including plugin roots, before generation preparation runs. When a module contains multiple designs, design-skew checks compare only trees attributed to the selected package. Version checks still inspect all generated trees in that module. Relative and import-path package spellings resolve to the same design identity.
Regenerate existing trees after upgrading to the complete provenance format. A manifest without a design package cannot be attributed safely and reports a regeneration diagnostic. The fingerprint covers evaluated public semantic state, not plugin source code, private caches, runtime callbacks or external files.
The command reports these high-confidence errors:
-
source analysis cannot parse or load an active package in the target module; the diagnostic names the package and includes the underlying Go load error
-
application code calls
Handledirectly on a Loom mux instead of mounting a generated route -
the same exact statically resolved manual method/path route is registered more than once
-
an exact statically resolved manual method/path route conflicts with an evaluated design operation
-
go.modand a generatedloom.jsoncontain different Loom versions -
generated output does not match the evaluated design recorded in
gen/loom.json; manifests without design provenance also require regeneration -
an application that opts into mount analysis has a designed HTTP service with no typed generated server
Mountcall in any configured package -
an HTTP 5xx error does not declare
Fault() -
an HTTP 429, 502, or 503 error declares neither
Temporary()nor a retry hint nested in a remedy:Remedy(func() { RetryHint("Retry after the dependency recovers.") })
It reports warnings when descriptions imply missing contract validation:
1-basedorzero-basedwithout a lower boundfrom X to Ywithout both numeric bounds- email, UUID, or URL strings without
Format(...)orPattern(...) - normalized numbers without bounds from 0 to 1
Anyattributes with strong scalar evidence, such as*_attimestamp names, UUID descriptions, or unambiguous boolean, integer, number, URI, email, or IP descriptions
untyped-semantic-attribute is not a blanket warning for Any. Generic
metadata, configuration, preference, and document fields do not warn by name.
Bare id and *_id names require UUID evidence, and descriptions that permit
multiple shapes do not trigger scalar inference.
Applications opt into service-not-mounted at API scope:
API("inventory", func() {
Meta("loom:vet:http-entrypoint", "./cmd/api", "./cmd/worker")
})Each value is a Go package pattern relative to the design module. Loom scans
consumer-owned files in those packages for typed generated HTTP server Mount
calls. Calls inside conditionals count; the rule does not attempt to prove which
runtime branches execute. Add every package that owns mounts. Libraries remain
opted out when this metadata is absent.
Source analysis follows the active packages selected by go list ./... for the
current build environment. It excludes nested dependency modules and does not
repeat ordinary Go type errors when complete syntax and type information remain
available for route inspection.
Text output is the default. JSON emits a diagnostics array. SARIF output uses
SARIF 2.1.0 for code-scanning integrations. The command exits with status 1
when it emits any diagnostic.
Suppress an evaluated-design diagnostic at its owning DSL expression:
Attribute("callback_url", String, func() {
Description("Callback URL or an intentional empty-string sentinel.")
Meta("loom:vet:ignore", "string-format")
})An intentionally unconstrained semantic-looking field can suppress the advisory warning at that attribute:
Attribute("created_at", Any, func() {
Description("Provider-owned value with no stable scalar representation.")
Meta("loom:vet:ignore", "untyped-semantic-attribute")
})The generated-design-skew rule is owned by the API expression:
API("inventory", func() {
Meta("loom:vet:ignore", "generated-design-skew")
})Use this suppression only when another workflow intentionally owns generated output. Regeneration clears the diagnostic in the normal workflow.
Suppress an intentionally unhosted designed service at service scope:
Service("reports", func() {
Meta("loom:vet:ignore", "service-not-mounted")
})Suppress an intentional direct Loom mux route with a reason immediately before the registration:
//loom:vet ignore route-outside-design -- process-local diagnostic endpoint
mux.Handle("GET", "/debug/status", debugStatus)The same source-comment form accepts duplicate-route-registration and
route-conflict-with-design. Dynamic method or path expressions are never
treated as duplicate or conflicting route facts.
Prefer fixing the design. Suppress only when the application intentionally owns the exception.
loom example <design-package-import-path> [-o <output-dir>] [--debug]A scaffolding command:
- Creates a one-time example implementation
- Generates handler stubs that return
loom.Fault - Does not create a success body, file, or stream event from a stub
- Run once when starting a new project
- Will NOT overwrite existing custom implementation
The transport encodes the fault as its standard internal-error response. Loom
does not add an undeclared HTTP 501 response to the design contract.
loom test-scaffold <design-package-import-path> [-o <output-dir>] [--debug]This command creates consumer-owned HTTP and gRPC response contract tests under
internal/contracttest/. Existing scaffold files are never overwritten. The
generated test enumerates the current server manifest and fails once for every
declared response that lacks an application callback, so later loom gen
changes remain visible without rewriting the scaffold.
Unary, multipart, SSE, and WebSocket cases use separate callback maps. A multipart
callback receives an loomhttp.MultipartRequestContract with the request
content type, part names, part media types, and required parts. The application
still owns multipart codecs and request fixtures. An SSE success callback returns
an loomhttp.SSEResponseContractObservation containing the handshake response,
parsed events, and terminal read error. The validator checks the handshake,
event data encoding, required ID and event-type fields, projection event types,
and clean stream completion.
A WebSocket callback receives the stream contract. It returns the upgrade
response, outbound JSON messages, and terminal read error. The validator checks
the 101 response, upgrade headers, JSON messages, and terminal behavior.
Declared errors that occur before an SSE frame or WebSocket upgrade remain
ordinary HTTP response cases.
Loom supports non-streaming multipart contracts for flat object bodies with primitive or bytes fields. Other multipart shapes produce a generation diagnostic. Multipart SSE and WebSocket endpoints also produce a diagnostic.
The gRPC scaffold publishes one stable case for each success or declared error.
Cases include the status code, protobuf success message or typed status detail,
and required header and trailer metadata. A callback receives the case and
returns a loomgrpc.ResponseContractObservation from a real generated client
call. Unary and server-streaming endpoints are supported. Server streams must
finish with clean EOF. Client-streaming and bidirectional endpoints produce a
generation diagnostic until their completion lifecycle is supported.
The JSON-RPC scaffold publishes success, declared error, and notification
suppression cases. Cases include the designed result type, error code, and
typed error-data name. Each callback returns a
jsonrpc.ResponseContractObservation from a real generated handler request.
Unary and server-SSE endpoints are supported. A server-SSE request with an ID
must finish with a final response. An ID-less stream must suppress that final
response. WebSocket, client-streaming, and bidirectional completion contracts
produce a generation diagnostic.
loom version- Create initial design
- Run
loom gento generate base code - Run
loom exampleto create implementation stubs - Run
loom test-scaffoldto create response-contract test providers - Implement your service logic and contract scenarios
- Run
loom genafter every design change
Best Practice: Commit generated code to version control rather than generating during CI/CD. This ensures reproducible builds and allows tracking changes in generated code.
For browser and TypeScript consumers, use the endorsed
@hey-api/openapi-ts recipe. It covers the OpenAPI 3.1
compatibility target, generated SDK and validation artifacts, session cookies,
and drift checks.
For published API compatibility, use the endorsed oasdiff workflow. It regenerates the contract, detects stale committed output, reports a Markdown changelog, and blocks breaking changes.
When you run loom gen, Loom follows a systematic process:
Loom creates a temporary main.go that:
- Imports Loom packages and your design package
- Runs DSL evaluation
- Triggers code generation
- DSL functions execute to create expression objects
- Expressions combine into a complete API model
- Relationships between expressions are established
- Design rules and constraints are validated
- Validated expressions pass to code generators
- Templates render to produce code files
- Output writes to the
gen/directory
A typical project after loom gen and loom example depends on the transports
used in the design. A service with HTTP and gRPC enabled looks like this:
myservice/
├── cmd/ # Example commands you can customize
│ └── calc/
│ ├── grpc.go
│ └── http.go
├── design/ # Your design files
│ └── design.go
├── gen/ # Generated code (don't edit)
│ ├── calc/ # Service-specific code
│ │ ├── client.go
│ │ ├── endpoints.go
│ │ └── service.go
│ ├── http/ # HTTP transport layer
│ │ ├── calc/
│ │ │ ├── client/
│ │ │ └── server/
│ │ ├── openapi.json
│ │ └── openapi.yaml
│ └── grpc/ # gRPC transport layer
│ └── calc/
│ ├── client/
│ ├── server/
│ └── pb/
└── myservice.go # Your service implementation
Small HTTP and JSON-RPC services keep compact types.go files. When a
generated transport package grows beyond the type-section split threshold,
Loom writes deterministic concern files such as types_requests.go,
types_responses.go, types_unions.go, types_validation.go, and
types_helpers.go in the same package. The exported Go API is unchanged; the
split only keeps large generated packages navigable and diff-friendly.
Large generated HTTP clients also expose deterministic operation
groups derived from the first route path segment, for example
client.Items.List() and client.Items.BuildListRequest(...). The flat
methods remain available on Client, so existing consumers keep compiling
while larger services gain a narrower navigation surface.
Synthesized examples in generated service and HTTP CLI code use stable service and method scopes. Adding, changing, removing, or reordering an unrelated service does not perturb another service's example values.
Generated in gen/<service>/service.go:
// Service interface defines the API contract
type Service interface {
Add(context.Context, *AddPayload) (res int, err error)
Multiply(context.Context, *MultiplyPayload) (res int, err error)
}
// Payload types
type AddPayload struct {
A int32
B int32
}
// Constants for observability
const ServiceName = "calc"
var MethodNames = [2]string{"add", "multiply"}Generated in gen/<service>/endpoints.go:
// Endpoints wraps service methods in transport-agnostic endpoints
type Endpoints struct {
Add loom.Endpoint
Multiply loom.Endpoint
}
// NewEndpoints creates endpoints from service implementation
func NewEndpoints(s Service) *Endpoints {
return &Endpoints{
Add: NewAddEndpoint(s),
Multiply: NewMultiplyEndpoint(s),
}
}
// Use applies middleware to all endpoints
func (e *Endpoints) Use(m func(loom.Endpoint) loom.Endpoint) {
e.Add = m(e.Add)
e.Multiply = m(e.Multiply)
}Endpoint middleware example:
func LoggingMiddleware(next loom.Endpoint) loom.Endpoint {
return func(ctx context.Context, req any) (res any, err error) {
log.Printf("request: %v", req)
res, err = next(ctx, req)
log.Printf("response: %v", res)
return
}
}
endpoints.Use(LoggingMiddleware)Generated in gen/<service>/client.go:
// Client provides typed methods for service calls
type Client struct {
AddEndpoint loom.Endpoint
MultiplyEndpoint loom.Endpoint
}
func NewClient(add, multiply loom.Endpoint) *Client {
return &Client{
AddEndpoint: add,
MultiplyEndpoint: multiply,
}
}
func (c *Client) Add(ctx context.Context, p *AddPayload) (res int, err error) {
ires, err := c.AddEndpoint(ctx, p)
if err != nil {
return
}
return ires.(int), nil
}HTTP and JSON-RPC server handler fields use names allocated for their concrete
receiver. A field can differ from its service method name when a fixed member
(such as Mount, Service, or Methods) or another handler occupies that name.
File handlers share the same receiver namespace. JSON-RPC WebSocket decoder and
endpoint fields remain distinct even when their names would otherwise overlap.
Service interfaces, authored routes and JSON-RPC method strings remain unchanged.
Regenerate after upgrading, and update any direct handler-field accesses to the
names in the generated Server declaration. Ordinary noncolliding names remain
unchanged.
Generated in gen/http/<service>/server/server.go:
func New(
e *calc.Endpoints,
mux loomhttp.Muxer,
decoder func(*http.Request) loomhttp.Decoder,
encoder func(context.Context, http.ResponseWriter) loomhttp.Encoder,
errhandler func(context.Context, http.ResponseWriter, error),
formatter func(ctx context.Context, err error) loomhttp.StatusCoder,
) *Server
// Server exposes handlers for modification
type Server struct {
Mounts []*MountPoint
Add http.Handler
Multiply http.Handler
}
// Use applies HTTP middleware to all handlers
func (s *Server) Use(m func(http.Handler) http.Handler)Complete server setup:
func main() {
svc := calc.New()
endpoints := gencalc.NewEndpoints(svc)
mux := loomhttp.NewMuxer()
server := genhttp.New(
endpoints,
mux,
loomhttp.RequestDecoder,
loomhttp.ResponseEncoder,
nil, nil)
genhttp.Mount(mux, server)
http.ListenAndServe(":8080", mux)
}Generated in gen/http/<service>/client/client.go:
func NewClient(
scheme string,
host string,
doer loomhttp.Doer,
enc func(*http.Request) loomhttp.Encoder,
dec func(*http.Response) loomhttp.Decoder,
restoreBody bool,
) *ClientComplete client setup:
func main() {
httpClient := genclient.NewClient(
"http",
"localhost:8080",
http.DefaultClient,
loomhttp.RequestEncoder,
loomhttp.ResponseDecoder,
false,
)
client := gencalc.NewClient(
httpClient.Add(),
httpClient.Multiply(),
)
result, err := client.Add(context.Background(), &gencalc.AddPayload{A: 1, B: 2})
}HTTP generation also emits command-line client support under
gen/http/cli/<server>/cli.go and per-service payload builders under
gen/http/<service>/client/cli.go. The generated parser is Kong-backed, so
service and method descriptions from the design become command help, while
Loom-owned generated code still constructs the typed endpoint and payload.
loom example wires that support into cmd/<server>-cli so local testing can
call any generated endpoint without hand-writing a test client:
go run ./cmd/calc-cli --url=http://localhost:8080 calc add --a=1 --b=2
go run ./cmd/calc-cli calc add --helpGenerated in gen/grpc/<service>/pb/:
syntax = "proto3";
package calc;
service Calc {
rpc Add (AddRequest) returns (AddResponse);
rpc Multiply (MultiplyRequest) returns (MultiplyResponse);
}
message AddRequest {
int64 a = 1;
int64 b = 2;
}func main() {
svc := calc.New()
endpoints := gencalc.NewEndpoints(svc)
svr := grpc.NewServer()
gensvr := gengrpc.New(endpoints, nil)
genpb.RegisterCalcServer(svr, gensvr)
lis, _ := net.Listen("tcp", ":8080")
svr.Serve(lis)
}func main() {
conn, _ := grpc.Dial("localhost:8080",
grpc.WithTransportCredentials(insecure.NewCredentials()))
defer conn.Close()
grpcClient := genclient.NewClient(conn)
client := gencalc.NewClient(
grpcClient.Add(),
grpcClient.Multiply(),
)
result, _ := client.Add(context.Background(), &gencalc.AddPayload{A: 1, B: 2})
}Loom generates HTTP servers, per-service clients, and the aggregate client CLI by default. A service that only hosts an HTTP API can generate server packages without unused client artifacts:
var _ = API("MyAPI", func() {
Meta("http:generate", "server")
})The server mode keeps generated service packages, HTTP server packages, and
OpenAPI output. It omits gen/http/<service>/client/ and gen/http/cli/ and
removes those directories when a project switches from the default all mode.
Force generation of types not directly referenced by methods:
var MyType = Type("MyType", func() {
// Force generation in specific services
Meta("type:generate:force", "service1", "service2")
// Or force generation in all services
Meta("type:generate:force")
Attribute("name", String)
})Generate types in a shared package:
var CommonType = Type("CommonType", func() {
Meta("struct:pkg:path", "types")
Attribute("id", String)
})Creates:
gen/
└── types/
└── common_type.go
Distinct Go types can share a generated filename. For example, FooBAR and
FooBar both use foo_bar.go; Loom retains both declarations. Repeated uses of
the same shared declaration emit it once. Conflicting definitions of one Go
symbol fail generation with the file and symbol names. Regenerate affected
shared-package designs after upgrading; do not rename types merely to avoid a
filename collision.
The path must not be the package of a service that uses the type: with
Meta("struct:pkg:path", "common") on a type of a service named common, the
service package would import itself, so loom gen fails and asks for another
path such as types/common. When the package has the name of the service
package, as with types/catalog in a service named catalog that has a transport,
the generated service package is named catalogsvc so that the transport
files can import both packages. A generated file that also imports another
package of the same name, such as types/security next to the Loom security
package or types/multipart next to mime/multipart, imports the type package
under an alias such as security2. The other files keep the package name.
Generated service and API packages use legal Go import identifiers. A name that
normalizes to init uses the package name init_, because Go reserves init
for initialization functions. The service directory remains gen/init, and
service names, routes and protocol names stay unchanged. Regenerate affected
code after upgrading; use the generated package name in handwritten imports.
A type in a shared package keeps its Go name even when a service method has
the same name. For example, Type("Moved", ...) in types is declared and
referenced as types.Moved even if a consuming service has a moved method.
This also applies to empty objects, primitive aliases, arrays, maps and unions.
var Message = Type("Message", func() {
Attribute("id", String, func() {
// Override field name
Meta("struct:field:name", "ID")
// Add custom struct tags
Meta("struct:tag:json", "id,omitempty")
Meta("struct:tag:msgpack", "id,omitempty")
// Override type
Meta("struct:field:type", "bson.ObjectId", "github.com/globalsign/mgo/bson", "bson")
})
})var MyType = Type("MyType", func() {
// Override protobuf message name
Meta("struct:name:proto", "CustomProtoType")
Field(1, "status", Int32, func() {
// Override protobuf field type
Meta("struct:field:proto", "int32")
})
// Use Google's timestamp type
Field(2, "created_at", String, func() {
Meta("struct:field:proto",
"google.protobuf.Timestamp",
"google/protobuf/timestamp.proto",
"Timestamp",
"google.golang.org/protobuf/types/known/timestamppb")
})
})
// Specify protoc include paths
var _ = API("calc", func() {
Meta("protoc:include", "/usr/include", "/usr/local/include")
})
// Override the protoc command for one gRPC service
var _ = Service("calc", func() {
Meta("protoc:cmd", "/usr/bin/protoc", "--fatal_warnings")
})var _ = API("MyAPI", func() {
// Control generation
Meta("openapi:generate", "false")
// Emit JSON only. Valid values are "json", "yaml", and "both" (default).
Meta("openapi:output", "json")
// Override the default two-space JSON formatting
Meta("openapi:json:prefix", " ")
Meta("openapi:json:indent", " ")
// Omit synthesized examples while retaining authored Example(...) values
Meta("openapi:example", "false")
// OpenAPI 3.2 is the default. Set 3.1 only for compatibility consumers;
// the same renderer skips 3.2-only sections.
Meta("openapi:version", "3.1")
// OpenAPI 3.2 document identity (omitted by the 3.1 target)
Meta("openapi:self", "https://example.com/openapi.json")
})
var _ = Service("UserService", func() {
// Add tags
HTTP(func() {
Meta("openapi:tag:Users")
Meta("openapi:tag:Backend:desc", "Backend API Operations")
Meta("openapi:tag-id:YmlsbGluZzpkZXNj:desc", "Billing tag") // billing:desc
})
Method("CreateUser", func() {
// Custom operation ID
Meta("openapi:operationId", "{service}.{method}")
// Custom summary
Meta("openapi:summary", "Create a new user")
HTTP(func() {
// Add extensions
Meta("openapi:extension:x-rate-limit", `{"rate": 100}`)
POST("/users")
})
})
})
var User = Type("User", func() {
// Override type name in OpenAPI spec
Meta("openapi:typename", "CustomUser")
})Tag identities use openapi:tag:<name> when the name has no colon. A name
containing a colon uses openapi:tag-id:<base64url-name>, where the name is
UTF-8 encoded with unpadded URL-safe Base64. Annotation suffixes such as
:desc, :parent, and :extension:x-name follow either identity form.
Identity metadata has no value; each annotation has exactly one value.
Loom synthesizes deterministic OpenAPI examples by default. Regenerating the
same design produces the same examples, and changing one schema does not shift
examples for unrelated operations or components. Set
Meta("openapi:example", "false") at API scope when committed specifications
should omit synthesized examples. Explicit Example(...) values remain in both
the JSON and YAML outputs. Loom emits both formats by default. Set
Meta("openapi:output", "json") or Meta("openapi:output", "yaml") to emit
only one; generation removes a stale unselected sibling file. JSON is
deterministically ordered, formatted with two-space indentation, and terminated
with a newline for readable contract diffs.
Loom validates data at system boundaries:
- Server-side: Validates incoming requests
- Client-side: Validates incoming responses
- Internal code: Trusted to maintain invariants
| Properties | Payload/Result | Request Body (Server) | Response Body (Server) |
|---|---|---|---|
| Required OR Default | Direct (-) | Pointer (*) | Direct (-) |
| Not Required, No Default | Pointer (*) | Pointer (*) | Pointer (*) |
Special types:
- Objects (structs): Always use pointers
- Arrays and Maps: Never use pointers (already reference types)
Example:
type Person struct {
Name string // required, direct value
Age *int // optional, pointer
Hobbies []string // array, no pointer
Metadata map[string]string // map, no pointer
}- Marshaling: Default values initialize nil arrays/maps
- Unmarshaling: Default values apply to missing optional fields (not missing required fields)
Views control how result types are rendered in responses.
- Service method includes a view parameter
- A views package is generated at the service level
- View-specific validation is automatically generated
- The service result is projected into the selected view
- The projected result is validated against that view
- The valid result is marshalled, omitting nil attributes
- The view name is passed in the "loom-view" header
An unknown service-selected view or a violated result constraint is a server
fault. Unary transports report HTTP 500, gRPC Internal, or JSON-RPC internal
error. Stream senders return the fault before emitting the invalid result;
a stream that has already started cannot change its initial HTTP status.
The fault wraps the original validation error. Errors returned by the service
itself retain their classification.
Only the selected view is validated: an invalid field excluded by a tiny view does not invalidate that response. A nil object result is a fault; nil and empty result collections remain valid empty collections, while non-nullable nil collection elements are rejected. Regenerate services to adopt these checks.
- Response is unmarshalled
- Transformed into viewed result type
- View name extracted from "loom-view" header
- View-specific validation performed
- Converted back to service result type
Fields omitted by the selected view stay absent during conversion, even when
the full result type requires them. Missing fields required by that view and
unknown view names return validation errors. An absent loom-view header selects
the default view. HTTP and WebSocket clients share this conversion behavior;
regenerate existing clients to pick up generator fixes.
For ResultType and View designs, Loom generates exported projection helpers
in the service package. These helpers convert canonical result values to view
types and view values back to canonical result types. They are used by generated
wrappers such as NewViewed... and by transport encoders/decoders, which keeps
HTTP, gRPC, and JSON-RPC projections aligned with the canonical result model.
Projection generation is recursive across nested structs, slices, maps, unions,
collections, and optional fields. Object projection helpers preserve nil values;
they perform conversion, not validation. The generated NewViewed...
constructor owns selected-view validation for endpoints, interceptors and stream
senders.
Every nested result selects its own default view unless the design explicitly selects a nested view. A parent view does not implicitly select the same view for its children. This rule applies through ordinary type wrappers, arrays, maps, collections and union branches. Fields excluded by a nested view are neither copied into outgoing values nor read during reverse conversion, even when the canonical result requires those fields. Regenerate existing services to apply this selection consistently to every container.
View fields inherit canonical requiredness unless the view uses
ViewRequired(...) or ViewOptional(...). Those overrides drive projected
validation, HTTP field pointers and JSON tags, and OpenAPI required arrays;
nested named views retain their own requiredness contract.
If no views are defined, Loom adds a "default" view that includes all basic fields.
Loom exposes a compile-time generator plugin registry for framework extensions
such as Loom-MCP. A plugin registers from a package imported by the design
dependency graph using codegen.RegisterPluginFirst, RegisterPlugin, or
RegisterPluginLast. Each registration can provide a prepare callback that
updates evaluated roots and a generation callback that adds or transforms
files.
Generation snapshots the registry before evaluation phases run, so concurrent
registrations cannot alter an in-progress run. Plugins execute in deterministic
groups (First, normal, then Last) and by name within each group. Treat this
as a compile-time extension contract: applications should use the design DSL
and runtime middleware for application behavior, while external framework
packages use plugins only for reproducible generated artifacts.
External plugins may populate codegen.File.SectionTemplates when their
sections are template-backed. Loom-owned generators use the generic
codegen.File.Sections API, but SectionTemplates remains a supported public
extension surface and is adapted by File.AllSections during rendering.
Framework-owned HTTP, gRPC, and JSON-RPC behavior still belongs directly in the relevant generator package. Generated section hooks are an implementation mechanism inside generators, not a second plugin lifecycle.
- DSL Reference — Complete DSL reference for design files
- HTTP Guide — HTTP transport features and customization
- gRPC Guide — gRPC transport features and Protocol Buffers
- Quickstart — Getting started with code generation