Skip to content

Latest commit

 

History

History
1411 lines (1128 loc) · 57.1 KB

code-generation.md

File metadata and controls

1411 lines (1128 loc) · 57.1 KB
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.

Command Line Tools

Installation

go install github.com/CaliLuke/loom/cmd/loom@v1.10.0-alpha.5

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

Pinning the Generator in a Module

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/design

This 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/design

Commands

Generation commands expect Go package import paths, not filesystem paths:

loom gen github.com/CaliLuke/loom-examples/calc/design

Import OpenAPI (loom import openapi)

loom 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-tags

Repeat 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, nil

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

Generate Code (loom gen)

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.

Analyze Adoption (loom vet)

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 Handle directly 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.mod and a generated loom.json contain 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 Mount call 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-based or zero-based without a lower bound
  • from X to Y without both numeric bounds
  • email, UUID, or URL strings without Format(...) or Pattern(...)
  • normalized numbers without bounds from 0 to 1
  • Any attributes with strong scalar evidence, such as *_at timestamp 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.

Create Example (loom example)

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.

Create Contract Tests (loom test-scaffold)

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.

Show Version

loom version

Development Workflow

  1. Create initial design
  2. Run loom gen to generate base code
  3. Run loom example to create implementation stubs
  4. Run loom test-scaffold to create response-contract test providers
  5. Implement your service logic and contract scenarios
  6. Run loom gen after 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.


Generation Process

When you run loom gen, Loom follows a systematic process:

1. Bootstrap Phase

Loom creates a temporary main.go that:

  • Imports Loom packages and your design package
  • Runs DSL evaluation
  • Triggers code generation

2. Design Evaluation

  • 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

3. Code Generation

  • Validated expressions pass to code generators
  • Templates render to produce code files
  • Output writes to the gen/ directory

Generated Project Structure

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.

Service Interfaces

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"}

Endpoint Layer

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)

Client Code

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 Code Generation

Server Implementation

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

Client Implementation

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,
) *Client

Complete 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})
}

Generated CLI Client

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 --help

gRPC Code Generation

Protobuf Definition

Generated 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;
}

Server Implementation

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

Client Implementation

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

Customization

HTTP Artifact Selection

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.

Type Generation Control

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

Package Organization

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.

Field Customization

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")
    })
})

Protocol Buffer Customization

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")
})

OpenAPI Customization

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.


Types and Validation

Validation Enforcement

Loom validates data at system boundaries:

  • Server-side: Validates incoming requests
  • Client-side: Validates incoming responses
  • Internal code: Trusted to maintain invariants

Pointer Rules for Struct Fields

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
}

Default Value Handling

  • Marshaling: Default values initialize nil arrays/maps
  • Unmarshaling: Default values apply to missing optional fields (not missing required fields)

Views and Result Types

Views control how result types are rendered in responses.

How Views Work

  1. Service method includes a view parameter
  2. A views package is generated at the service level
  3. View-specific validation is automatically generated

Server-Side Response

  1. The service result is projected into the selected view
  2. The projected result is validated against that view
  3. The valid result is marshalled, omitting nil attributes
  4. 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.

Client-Side Response

  1. Response is unmarshalled
  2. Transformed into viewed result type
  3. View name extracted from "loom-view" header
  4. View-specific validation performed
  5. 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.

Projection Helpers

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.

Default View

If no views are defined, Loom adds a "default" view that includes all basic fields.


Extension Points

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.


See Also

  • 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