Schema codegen
@di-framework/codegen turns versioned, typed schema manifests into deterministic application surfaces: validation helpers, an HTTP controller with OpenAPI metadata, @EventBridge routes, and opt-in RPC services and AI tools. You write the schemas, the manifest, and the handler; the generator writes the glue and keeps it in sync.
di-framework generate is the only entry point most projects need. The package also exposes a typed generate() function for build scripts.
Features
Typed manifests: plain TypeScript objects checked with
satisfies SchemaCodegenManifest. No DSL, no.proto, no YAML.Deterministic emitters: every surface is sorted and stable, so repeated runs produce byte-identical output and clean diffs.
One handler, many transports: HTTP, RPC, and tool surfaces validate input, call the same handler method, and validate output. The handler receives the transport name, and the HTTP route also passes the request.
OpenAPI for free: generated routes carry
@Endpointmetadata built from the manifest and the schemas'jsonSchema, sodi-framework http openapi generatedocuments them.Companion skeletons (
--init): creates missing handler classes and@Policyskeletons once and never overwrites them.Ownership ledger: generated files are listed in
.codegen-ledger.jsonand start with an ownership header. Only files that carry the header are ever deleted.Drift check (
--check): verifies committed output matches the manifests without writing anything. Exit code1on drift.
Installation
@di-framework/core is a peer dependency. Install the packages for the surfaces you generate: @di-framework/http, @di-framework/events, @di-framework/rpc, @di-framework/ai, and @di-framework/authz for policy skeletons.
The generator discovers manifest files with Bun.Glob, so run it with Bun. Direct file paths work in any runtime.
Project layout
A default project looks like this:
Output for each manifest goes to <outDir>/<name>/<version>/. Two versions of the same contract coexist, which is how you ship v2 without touching v1.
Configuration
Create di-framework.codegen.ts in the project root. Every field is optional; the values below are the defaults.
Field | Purpose |
|---|---|
| Glob patterns or file paths, relative to the working directory. |
| Root for generated surfaces. Each manifest writes to |
| Reserved for handler skeletons. Handlers are placed at the manifest's |
| Where |
| Ownership ledger location. Defaults to |
The generator looks for di-framework.codegen.ts, di-framework.codegen.js, codegen.config.ts, then codegen.config.js. Pass --config <path> to use another file. Without any config file, the defaults apply.
Runtime schemas
A runtime schema is any object with a parse(input) method and a jsonSchema property. Zod, Valibot, ArkType, or a hand-written validator all qualify; the generator never imports a validation library itself.
Export the type and the value under the same name. Generated code imports both: the type for signatures and the value for parse and jsonSchema.
Manifests
A manifest exports one default object. name and version become the output directory and the generated class names (OrdersV1HttpController, OrdersV1Events, and so on).
schemas
Maps a schema name to a runtime schema. The generator needs to know which module exports each schema so it can emit imports:
A bare runtime schema (
CreateOrder) is assumed to live in./<name>.schemasnext to the manifest, soordersresolves to./orders.schemas.{ schema, module }names the module explicitly:{ schema: CreateOrder, module: './shared/order.schemas.ts' }.
Module paths are relative to the manifest file. Extensions are stripped from emitted import specifiers.
operations
Each operation needs input, output, and handler. Everything else is opt-in and turns on one surface.
Key | Surface | Notes |
|---|---|---|
| all |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Operations and schemas are processed in sorted order regardless of how the manifest lists them.
Include the .ts extension in handler.module and policyModule. --init creates the file at exactly that path, and generated imports drop the extension anyway.
Generated surfaces
All generated files start with this header. The ledger and --clean treat it as proof of ownership:
contracts.ts
One validate<Schema>(input: unknown): <Schema> function per schema, delegating to parse. Every other surface imports from here.
http.ts
A @Controller class named <Name><Version>HttpController plus a module-level TypedRouter exported as routes. Each HTTP operation becomes a static route field on the controller, registered on routes at the manifest prefix joined with the operation path, so prefix: '/v1' and path: '/orders' register /v1/orders.
The route resolves the controller from the container, takes the parsed request content, validates it as the input schema, and calls the handler with { transport: 'http', request }. If the handler returns a Response it is sent as is, which is the escape hatch for redirects, streams, and custom headers. Any other result is validated as the output schema and returned with json(...) and the configured status.
Every route carries @Endpoint metadata: summary, description, and parameters from the manifest, a JSON requestBody built from the input schema for POST, PUT, and PATCH, and a response for successStatus built from the output schema. A 204 response has no body. Run di-framework http openapi generate --controllers ./src/generated/orders/v1/http.ts to emit the document.
Input always comes from the request content. GET and HEAD routes fall back to {} when there is no body, so path and query parameters are documented through parameters but are not mapped onto the input schema.
Handlers are injected with @Component. When every HTTP operation in a manifest shares one handler class the property is named handlers; otherwise each class gets its own property.
Serve the routes by importing the module, which also registers the controller with the container:
Each manifest version exports its own routes. Mount several with one outer TypedRouter or serve them from separate entry points.
events.ts
An @EventBridge class named <Name><Version>Events. An inbound route declares @Inbound({ topic, event, validate }) with the input validator, so broker messages are checked before they reach the container bus. An outbound route declares @Outbound(event, { topic }). The events surface declares routes only; it does not call the handler. Subscribe to the inbound event with @Subscriber as usual.
rpc.ts
Emitted when any operation sets rpc. One @RpcMessage class per operation input (<Operation>Request) and output (<OutputSchema>Message), with @RpcField numbers from inputFields and outputFields, plus an @RpcService({ package }) class named <Name><Version>RpcService with one @RpcMethod per operation. The method validates input, calls the handler with { transport: 'rpc' }, and validates the output.
A field can be a bare number (customerId: 1), which means a string, or { number, type }. Numeric types (double, float, int32, int64, uint32, uint64) emit number, bool emits boolean, and everything else emits string.
tools.ts
Emitted when any operation sets tool. A @ToolSet and @Container class named <Name><Version>Tools with one @Tool per operation. The tool's inputSchema is the input schema's jsonSchema. auth.resource on the tool set is the first operation's authorization.resource, or the manifest name when none is set; auth.action on each tool is the operation's authorization.action, or execute. The tool validates input, calls the handler with { transport: 'ai-tool' }, and validates the output.
Companion skeletons
di-framework generate --init creates files that you own. It never overwrites an existing file, so run it whenever you add an operation and fill in what appears.
For each operation whose handler.module is missing:
For each operation with authorization whose policy module is missing:
The HTTP route passes the incoming Request in the context, so declare request?: Request on IngressContext as shown if your handler needs headers, cookies, or the URL.
Generated surfaces do not import the policy. Bind it to the controller or resolver as described in Resource Authorization.
Ledger, drift, and cleanup
Every run writes .codegen-ledger.json listing the files it produced. On the next run, a file in the ledger that no manifest produces anymore is stale:
di-framework generatereports it and leaves it in place.di-framework generate --cleandeletes it, but only when the file still starts with the ownership header. Files without the header, including companion skeletons, are reported and skipped.di-framework generate --checktreats it as drift.
--check never writes. It exits 1 when a generated file is missing, differs from what the manifests produce, or is stale. Wire it into CI next to di-framework check:
Commit the generated directory and the ledger. The check keeps them honest, and committed output means tsc, editors, and reviewers see the real code.
CLI
Flag | Description |
|---|---|
| Config file to load instead of the discovered default. |
| Override |
| Create missing handler and policy skeletons. |
| Report drift without writing. Exit |
| Delete stale files that carry the ownership header. |
Each file is listed with its status (created, updated, unchanged, deleted, or drifted). With --json, data is the GenerateResult described below. Unknown arguments exit 2. See CLI for the shared output contract.
Programmatic API
GenerateOptions also accepts manifests: SchemaCodegenManifest[] to bypass file discovery and outDir to override the configured directory. GenerateResult has success, drifted, files (each with path, relativePath, content, and status), ledgerPath, and diagnostics.
Lower-level exports are available for custom pipelines: loadConfig, findManifestFiles, loadManifests, validateManifestShape, normalizeManifest, normalizeImportPath, loadLedger, saveLedger, hasOwnershipHeader, and OWNERSHIP_HEADER.
Versioning contracts
Add a new manifest rather than editing a published one. orders-v2.codegen.ts with version: 'v2' and http: { prefix: '/v2' } emits into src/generated/orders/v2/ with its own OrdersV2HttpController and routes, so both versions serve side by side until you delete the v1 manifest and run --clean.
Next Steps
CLI -
generatein the canonical command tree and the JSON output contractHTTP Router - What the generated controller plugs into, and OpenAPI generation
Events - Transports for the generated
@EventBridgeRPC - Serving the generated
@RpcServiceover JSON-RPC and gRPCAI - Registering the generated
@ToolSetwith an agentResource Authorization - Filling in the generated
@Policy