@hexaijs/plugin-contracts-generator
v0.5.1
Published
Collect domain events and commands from bounded contexts and generate frontend-compatible contracts package
Readme
@hexaijs/plugin-contracts-generator
Extract message contracts and general TypeScript contracts from backend source code to generate frontend-compatible types
Overview
@hexaijs/plugin-contracts-generator solves the problem of keeping frontend and backend type definitions in sync. In a hexagonal architecture, your backend defines domain events, commands, queries, and shared public contracts - but your frontend also needs type-safe access to these message types and general contract declarations for API calls, event handling, and validation.
Instead of manually duplicating type definitions (which inevitably drift out of sync), this plugin scans your backend source code for contract decorators and contract markers, then extracts the matching declarations into one or more standalone contracts packages. The generated package can contain the public API surface, an internal build-tool surface, or both via configured outputs - message types, their payloads, response types, and explicitly marked general contracts - without backend implementation details.
The plugin works at build time by:
- Scanning TypeScript files for message classes decorated with
@ContractEvent(),@ContractCommand(), or@ContractQuery(), generic@Contract({ kind })declarations, plus leading@Contract(...)comment markers - Resolving all type dependencies (including response types, shared value objects, and general contract declarations)
- Generating clean contracts packages with namespace exports and, when requested, a MessageRegistry for selected decorated messages only
Installation
npm install @hexaijs/plugin-contracts-generatorPeer dependencies:
typescript ^5.0.0 || ^6.0.0
Core Concepts
Contract Decorators
The package provides message-specific decorators and a generic contract decorator. These decorators have no runtime overhead - they simply tag classes for discovery during the build process. Selected message contracts are the only generated contracts registered in MessageRegistry.
import {
Contract,
ContractCommand,
ContractEvent,
ContractQuery,
} from "@hexaijs/contracts/decorators";@ContractEvent() - Marks a domain event contract:
import { DomainEvent } from "@hexaijs/core";
import { ContractEvent } from "@hexaijs/contracts/decorators";
@ContractEvent()
export class OrderPlaced extends DomainEvent<{
orderId: string;
customerId: string;
totalAmount: number;
}> {
static readonly type = "order.order-placed";
}@ContractCommand() - Marks a command contract:
import { ContractCommand } from "@hexaijs/contracts/decorators";
@ContractCommand({ response: "CreateOrderResponse" })
export class CreateOrderRequest extends BaseRequest<{
customerId: string;
items: OrderItem[];
}> {
static type = "order.create-order";
}
export type CreateOrderResponse = {
orderId: string;
};@ContractQuery() - Marks a query contract:
import { ContractQuery } from "@hexaijs/contracts/decorators";
@ContractQuery({ response: "OrderDetails" })
export class GetOrderQuery extends BaseRequest<{
orderId: string;
}> {}
type OrderDetails = {
orderId: string;
status: string;
items: OrderItem[];
};@Contract({ kind }) - Marks a generic contract role. Built-in message kinds are command, query, and event; custom kinds such as read-model, value-object, dto, or snapshot are treated as general contracts unless they are one of the built-in message kinds.
import { Contract } from "@hexaijs/contracts/decorators";
@Contract({ kind: "read-model", tags: ["frontend"] })
export class OrderListItem {
orderId!: string;
status!: string;
totalAmount!: number;
}
@Contract({ kind: "command", visibility: "internal" })
export class RebuildOrderProjectionCommand {
static type = "order.rebuild-projection";
}Each decorator accepts optional configuration:
context- Override the context name for this messageversion- Specify a version number for versioned eventsresponse- Explicitly name the response type (for commands/queries)visibility- Select the boundary for output filtering. Defaults to"public"; use"internal"for contracts that must not be emitted to public outputs unless selected explicitlytags- Auxiliary labels for additional output filters. Tags are not a security boundarykind- Generic contract role/discriminator for@Contract(...); message-specific decorators provide it implicitly
Use visibility for public/internal separation. Use tags only for secondary grouping such as "frontend", "bus", "admin", or "experimental".
Contract Comment Markers
General contracts that are not messages can be exposed with @Contract({ kind: "contract" }) or a custom kind. Classes support the no-op runtime decorator form. Interfaces, type aliases, and enums do not support TypeScript decorators, so they must use a leading comment marker.
Comment markers can be line comments, block comments, or JSDoc comments placed immediately before a class, interface, type, or enum declaration. Interfaces, type aliases, and enums are comment-marker only. Comment markers require no import. They must use call syntax such as // @Contract({ kind: "dto" }) or // @PublicContract(); bare markers such as // @PublicContract are not supported. If the marked declaration is not exported in the source file, the generator adds export in the generated contracts output.
@Contract({ kind: "snapshot" })
export class OrderSnapshotContract {
constructor(public readonly orderId: string) {}
}
// @Contract({ kind: "snapshot", visibility: "public", tags: ["frontend"] })
interface OrderSnapshot {
orderId: string;
status: OrderStatus;
totalAmount: number;
}
/* @Contract({ kind: "value-object" }) */
enum OrderChannel {
Online = "online",
Store = "store",
}
/** @Contract({ kind: "read-model", visibility: "internal", tags: ["admin"] }) */
type OrderStatus = "draft" | "placed" | "cancelled";General contracts are included in the generated contracts output, but they are not message contracts and are never registered in MessageRegistry. MessageRegistry registers selected decorated messages only.
Legacy @PublicContract() comment markers still work and map to @Contract({ kind: "contract", visibility: "public" }).
Configuration
Create an application.config.ts file in your monorepo root:
// application.config.ts
export default {
contracts: {
// Context definitions (required)
contexts: [
{
name: "order",
path: "packages/order",
tsconfigPath: "tsconfig.json", // optional, relative to path
},
{
name: "inventory",
path: "packages/inventory",
sourceDir: "lib", // optional, defaults to "src"
},
],
// Path alias rewrite rules (optional)
pathAliasRewrites: {
"@myorg/": "@/",
},
// Additional dependencies for contracts package (optional)
externalDependencies: {
"@hexaijs/core": "workspace:*",
},
// Multiple outputs are optional. If omitted, use --output-dir.
outputs: [
{
name: "public",
path: "packages/contracts/src",
select: {
visibility: ["public"],
},
},
{
name: "internal",
path: "packages/contracts-internal/src",
registry: true,
select: {
visibility: ["internal"],
messageKinds: ["command"],
tags: { include: ["bus"] },
},
},
],
// Response type naming conventions (optional)
responseNamingConventions: [
{ messageSuffix: "Command", responseSuffix: "CommandResult" },
{ messageSuffix: "Query", responseSuffix: "QueryResult" },
{ messageSuffix: "Request", responseSuffix: "Response" },
],
// Legacy custom decorator names (optional, defaults shown)
decoratorNames: {
event: "PublicEvent",
command: "PublicCommand",
query: "PublicQuery",
},
// Legacy custom comment marker names for general contracts (optional, defaults shown)
contractMarkerNames: {
contract: "PublicContract",
},
// Trusted local barrels that re-export Contract* decorators (optional)
trustedDecoratorSources: ["@app/contracts"],
// Entry strategy (optional, default: "symbols")
entryStrategy: "symbols",
// Generated relative import/export specifiers (optional, default: "js")
// Use "extensionless" only for legacy generated packages.
outputModuleSpecifiers: "js",
// Strip decorators from generated output (optional, default: true)
removeDecorators: true,
},
};The canonical API names ContractEvent, ContractCommand, ContractQuery, and Contract are recognized when they are imported from trusted decorator sources. Unbound canonical Contract* names are ignored to avoid false positives. decoratorNames and contractMarkerNames keep their legacy replacement semantics for existing projects that use custom Public* marker names.
When removeDecorators: true is enabled, generated files are printed through the TypeScript printer after matched contract decorators and related imports are removed. Migration diffs can therefore include formatting churn around affected declarations in addition to the expected decorator removal.
Each context requires name and path. The path is the base directory of the context (relative to the config file). Within that directory:
sourceDirdefaults to"src"(resolved relative topath)tsconfigPathdefaults to"tsconfig.json"(resolved relative topath)
For monorepos with many packages, use glob patterns to auto-discover contexts:
export default {
contracts: {
contexts: ["packages/*"], // Matches all directories under packages/
},
};Each matched directory is treated as a context with sensible defaults:
- Context name = directory name (e.g.,
packages/auth→auth) - Source directory =
src/(default) - TypeScript config =
tsconfig.json(auto-detected if exists)
Configuration Reference
| Field | Description |
|-------|-------------|
| contexts | Required context definitions or glob patterns |
| outputs | Optional multi-output plans. Omit for single-output CLI mode with --output-dir |
| entryStrategy | symbols for strict declaration extraction (default), or graph for conservative entry file graph copying |
| outputModuleSpecifiers | Generated relative module specifier style. Defaults to "js" for NodeNext/ESM-safe .js imports and exports. Use "extensionless" only for legacy generated packages |
| removeDecorators | Removes matched contract decorators from generated output by default |
| trustedDecoratorSources | Additional import sources trusted for canonical Contract* decorators |
Multiple Outputs
Use contracts.outputs[] when a monorepo needs more than one generated package. Output paths are resolved relative to the config file. When outputs[] is configured, run without --output-dir/-o; the CLI rejects the combination to avoid writing the same run to two different output plans.
Simple monorepo root config:
// application.config.ts
export default {
contracts: {
contexts: ["packages/*"],
outputs: [
{
name: "public",
path: "packages/contracts/src",
select: { visibility: ["public"] },
},
],
},
};Public/internal split:
export default {
contracts: {
contexts: [
{ name: "orders", path: "packages/orders" },
{ name: "billing", path: "packages/billing" },
],
outputs: [
{
name: "public",
path: "packages/contracts/src",
select: {
visibility: ["public"],
include: "all",
},
},
{
name: "internal-command-bus",
path: "packages/internal-contracts/src",
registry: true,
select: {
visibility: ["internal"],
messageKinds: ["command"],
tags: { include: ["bus"] },
},
},
],
},
};outputs[].select supports:
| Field | Description |
|-------|-------------|
| visibility | Primary public/internal boundary. Use ["public"] for frontend packages and ["internal"] for internal build targets |
| kinds | Match any contract kind, including custom generic kind values |
| messageKinds | Match only message kinds: command, query, or event |
| include | Select contract categories: all, messages, or contracts |
| tags.include | Keep contracts that have at least one included tag |
| tags.exclude | Drop contracts that have any excluded tag |
outputs[].registry: true generates a MessageRegistry for that output. General contracts are still exported but never registered.
outputs[].outputModuleSpecifiers can override the global contracts.outputModuleSpecifiers setting for one output.
If outputs[] is omitted, existing single-output mode remains unchanged:
npx generate-contracts --output-dir packages/contracts/srcCLI filters such as --include, --messages, and --registry still apply to single-output mode. With outputs[], use output-level select and registry; passing --registry enables registry generation for every configured output.
Response Types
Commands and queries often have associated response types. The generator includes these in the contracts package automatically.
Automatic detection via naming conventions:
// When responseNamingConventions includes { messageSuffix: "Command", responseSuffix: "CommandResult" }
@ContractCommand()
export class CreateOrderCommand extends Message<{ customerId: string }> {}
type CreateOrderCommandResult = { // Automatically detected by naming pattern
orderId: string;
};Explicit response option:
@ContractCommand({ response: "OrderCreationResult" })
export class CreateOrder extends Message<{ customerId: string }> {}
type OrderCreationResult = {
orderId: string;
createdAt: Date;
};Response types must be in the same file as the command/query. Both type aliases and interface declarations are supported. The generator adds export automatically if the type isn't already exported.
Entry vs Dependency Files
The generator handles two types of files differently:
Entry files (files with message decorators, @Contract(...) class decorators, or leading @Contract(...) comment markers) are contract entry points:
- The default
entryStrategyissymbols, which extracts selected declarations and filters imports for generated contract packages - Use
entryStrategy: "graph"or--entry-strategy graphwhen you intentionally want to copy selected entry files and their dependency graphs - Under
graph, message/output filters select graph roots and registry entries only; selected entry files can still be copied whole with other declarations from the same file, and the generator logs a warning when filters or strict output selection are used - In
symbols, matching decorated message classes and marked public contract declarations are extracted with minimal local dependencies - In
symbols, selected entry files preserve retained default imports, namespace imports, named aliases, mixed default + named imports, type-only default imports, and qualified type references such asTypes.UserorTypes.Inner.User - In
symbols, unused named specifiers in retained mixed imports are removed when the AST shape is safe to rewrite, and already-exported local function dependencies are preserved without adding a duplicateexport - Response types are included based on naming conventions
Dependency files (imported by entry files) are copied entirely:
- Supports barrel files (
export * from './module') - Resolves NodeNext-style source imports such as
./shared.jsback to TypeScript files such asshared.ts, including type-only dependency files - Preserves all exports for transitive dependencies
- Ensures type dependencies remain intact
symbols is still an AST-based slicer, not a full TypeScript TypeChecker semantic slicer. Dependency files referenced by retained local imports are copied as whole files; they are not symbol-sliced. With strict output selectors, the generator fails fast with BoundaryViolationError if copying would include a marked declaration outside the selected output. Keep public DTO/value-object dependencies boundary-clean and separate from internal implementation modules.
Generated Module Specifiers
Generated relative imports and exports use .js by default. This makes the emitted TypeScript source safe for moduleResolution: "NodeNext" and ESM package output:
- Context barrels emit exports such as
export * from "./events.js". - Copied local relative import/export declarations are normalized to
.jswhen they target copied local files. - Root registry namespace imports and exports use
./context/index.jsinstead of directory imports.
Set outputModuleSpecifiers: "extensionless" to preserve legacy extensionless generated output.
Usage
CLI
Run the generator from your monorepo root:
# In single-output mode, --output-dir (-o) specifies where contracts are generated
npx generate-contracts --output-dir packages/contracts/src
# Specify config file path (default: application.config.ts)
npx generate-contracts -o packages/contracts/src --config ./app.config.tsBy default, the CLI uses --include all, all message types, and --entry-strategy symbols. In single-output mode this generates public @ContractEvent(), @ContractCommand(), and @ContractQuery() message contracts plus marked general @Contract(...) declarations as a strict public contract surface. Legacy Public* markers are still recognized.
Use --output-module-specifiers js for the default NodeNext-safe .js generated relative imports and exports, or --output-module-specifiers extensionless for legacy generated packages that still require extensionless module specifiers.
| Option | Description |
|--------|-------------|
| -o, --output-dir <path> | Output directory for single-output mode; required unless contracts.outputs[] is configured |
| -c, --config <path> | Config file path (default: application.config.ts) |
| --include <scope> | Select generated contract categories: all, messages, or contracts |
| --messages <types> | Recommended message subtype filter. Accepts comma-separated event, command, and query values |
| -m, --message-types <types> | Legacy alias for --messages; kept for backwards compatibility |
| --entry-strategy <strategy> | Entry strategy: symbols strictly extracts selected declarations (default); graph copies selected entry file graphs |
| --registry | Generate the root MessageRegistry export |
| --generate-message-registry | Legacy verbose alias for --registry |
| --dry-run | Print the planned context extraction and file summary without writing files |
| --check | Verify generated output freshness for CI and exit non-zero when changes are required |
--include contracts generates only general contract declarations. --include messages generates only decorated messages. --messages filters only the message subtypes and does not exclude general contracts when --include all is used. In the default symbols strategy, retained local imports can use default, namespace, aliased named, mixed default + named, and type-only default import forms; qualified namespace references are tracked in selected entry files. Use --entry-strategy graph for conservative entry file graph copying. Under graph, message filters and output selectors choose graph roots and registry entries only; selected entry files can still be copied whole with other declarations from the same file, and the generator logs a warning. Use symbols for strict public/internal splits. The generated MessageRegistry registers selected decorated messages only; general contracts are never registered.
hexai CLI Plugin
When loaded through hexai.config.ts, the same options are available through the hexai plugin command:
// hexai.config.ts
export default {
plugins: [
{
plugin: "@hexaijs/plugin-contracts-generator",
config: {
contexts: ["packages/*"],
entryStrategy: "symbols",
contractMarkerNames: { contract: "PublicContract" },
},
},
],
};pnpm hexai generate-contracts -o packages/contracts/src --registry
pnpm hexai generate-contracts -o packages/contracts/src --include messages --messages event,command
pnpm hexai generate-contracts -o packages/contracts/src --entry-strategy graphFor configured outputs, put outputs[] in the plugin config and run without -o:
// hexai.config.ts
export default {
plugins: [
{
plugin: "@hexaijs/plugin-contracts-generator",
config: {
contexts: ["packages/*"],
outputs: [
{
name: "public",
path: "packages/contracts/src",
select: { visibility: ["public"] },
},
{
name: "internal",
path: "packages/contracts-internal/src",
registry: true,
select: { visibility: ["internal"] },
},
],
},
},
],
};pnpm hexai generate-contractsPassing -o/--output-dir together with configured outputs[] is rejected.
Common workflows:
# Preview the plan, then generate contracts with a MessageRegistry
npx generate-contracts -o packages/contracts/src --dry-run
npx generate-contracts -o packages/contracts/src --registry
# Generate messages only
npx generate-contracts -o packages/contracts/src --include messages --messages event,command
# Generate general contracts only
npx generate-contracts -o packages/contracts/src --include contracts
# Opt into conservative entry file graph copying
npx generate-contracts -o packages/contracts/src --messages event --entry-strategy graph
# CI freshness check
npx generate-contracts -o packages/contracts/src --check
# Generate configured outputs without --output-dir
npx generate-contracts --config application.config.tsImport and Source Matching
Decorator syntax is import/source-aware for canonical Contract* names. The matcher trusts decorators imported as named value imports from:
@hexaijs/contracts@hexaijs/contracts/decorators- any configured
contracts.trustedDecoratorSources[]entry
Named import aliases are supported:
import { ContractCommand as InternalCommand } from "@hexaijs/contracts/decorators";
@InternalCommand({ visibility: "internal", tags: ["bus"] })
export class RebuildSearchIndexCommand {}Same-named decorators imported from unrelated packages are ignored. Type-only imports are not treated as decorator bindings. Comment markers need no import because they are matched from declaration-leading comments.
Local barrels are intentionally conservative. If a local package re-exports the contract decorators, add that import source to contracts.trustedDecoratorSources or prefer importing directly from @hexaijs/contracts/decorators. The generator does not automatically trace arbitrary multi-hop re-export chains.
Namespace decorator imports are not supported:
import * as Contracts from "@hexaijs/contracts/decorators";
@Contracts.ContractCommand() // Not matched as a contract decorator
export class CreateOrderCommand {}Namespace imports remain supported for ordinary type dependencies in generated contract files; only namespace decorator calls are unsupported.
Migration from Public* Markers
PublicEvent, PublicCommand, PublicQuery, and PublicContract still work as deprecated aliases. They map to canonical Contract* metadata with visibility: "public". The runtime decorators do not emit deprecation warnings.
Recommended migration:
- Replace imports from
PublicEvent,PublicCommand, andPublicQuerywithContractEvent,ContractCommand, andContractQuery. - Replace
@PublicContract()class decorators with@Contract({ kind: "contract" })or a more specific custom kind such asread-model,value-object,dto, orsnapshot. - Replace
// @PublicContract()comment markers with// @Contract({ kind: "contract" })or a specifickind. - Add
visibility: "internal"only to contracts that should be excluded from public outputs. - Add
outputs[]withselect.visibilitybefore publishing internal contracts from the same source tree. - Keep
entryStrategy: "symbols"for strict public/internal split generation.
Programmatic API
For custom build scripts:
import { processContext, ConsoleLogger } from "@hexaijs/plugin-contracts-generator";
const result = await processContext({
contextName: "order",
path: "packages/order",
sourceDir: "src",
outputDir: "packages/contracts/src",
pathAliasRewrites: new Map([["@myorg/", "@/"]]),
contractMarkerNames: { contract: "PublicContract" },
messageTypes: ["event", "command"],
includePublicContracts: true,
entryStrategy: "symbols",
outputModuleSpecifiers: "js",
responseNamingConventions: [
{ messageSuffix: "Command", responseSuffix: "CommandResult" },
],
logger: new ConsoleLogger({ level: "info" }),
});
console.log(
`Extracted ${result.events.length} events, ` +
`${result.commands.length} commands, ` +
`${result.publicContracts.length} public contracts`
);For fine-grained control, use the ContractsPipeline class which provides step-by-step execution: scan(), parse(), resolve(), copy(), and exportBarrel().
Output Structure
The generated contracts package follows this structure:
contracts/
├── src/
│ ├── {context}/
│ │ ├── events.ts
│ │ ├── commands.ts
│ │ ├── queries.ts
│ │ ├── types.ts # Dependent types + Response types
│ │ └── index.ts # Barrel exports
│ └── index.ts # Namespace exports + MessageRegistry for messages
├── package.json
└── tsconfig.jsonThe root index.ts uses namespace exports to prevent name collisions:
// contracts/src/order/index.ts
export * from "./events.js";
export * from "./commands.js";
export * from "./types.js";
// contracts/src/index.ts
import { MessageRegistry } from "@hexaijs/plugin-contracts-generator/runtime";
import * as order from "./order/index.js";
import * as inventory from "./inventory/index.js";
export * as order from "./order/index.js";
export * as inventory from "./inventory/index.js";
export const messageRegistry = new MessageRegistry()
.register(order.OrderPlaced)
.register(inventory.StockUpdated);Only decorated events, commands, and queries are registered. General contracts marked with @Contract(...) comments are exported through the generated package but are not registered.
Use namespace exports in your frontend:
import { order, messageRegistry } from "@myorg/contracts";
// Access types via namespace
const event = new order.OrderPlaced({ orderId: "123", customerId: "456" });
// Deserialize messages from the backend
const message = messageRegistry.dehydrate(header, body);Error Handling
The generator provides specific error types for different failure modes:
import {
processContext,
MessageParserError,
FileReadError,
ConfigLoadError,
} from "@hexaijs/plugin-contracts-generator";
try {
await processContext(options);
} catch (error) {
if (error instanceof FileReadError) {
console.error(`Failed to read: ${error.path}`, error.cause);
} else if (error instanceof ConfigLoadError) {
console.error(`Config error: ${error.message}`);
} else if (error instanceof MessageParserError) {
console.error(`Parser error: ${error.message}`);
}
}Error hierarchy:
MessageParserError(base)BoundaryViolationErrorConfigurationError→ConfigLoadErrorFileSystemError→FileNotFoundError,FileReadError,FileWriteErrorParseError→JsonParseErrorResolutionError→ModuleResolutionError
API Highlights
| Export | Description |
|--------|-------------|
| processContext(options) | Main API for extracting and copying contracts |
| ContractsPipeline | Fine-grained control over extraction process |
| ContractEvent | Decorator to mark event messages for extraction and registry generation |
| ContractCommand | Decorator to mark command messages for extraction and registry generation |
| ContractQuery | Decorator to mark query messages for extraction and registry generation |
| Contract | Generic decorator for message and non-message contract roles through kind |
| PublicEvent, PublicCommand, PublicQuery, PublicContract | Deprecated compatibility aliases for the canonical Contract* API; no runtime warnings |
| ContractDeclaration | Canonical domain model for selected message and general contract declarations |
| PublicContract type | Compatibility domain model for marked class, interface, type, and enum declarations |
| ContractOutputConfig | outputs[] configuration shape for output-level path, selector, registry, and module specifier settings |
| ContractMarkerNames | Configuration shape for customizing legacy public contract comment marker names |
| EntryStrategy | symbols for default strict declaration extraction, or graph for entry file graph copy |
| OutputModuleSpecifiers | "js" for default NodeNext-safe .js generated specifiers, or "extensionless" for legacy generated specifiers |
| MessageRegistry | Runtime registry for decorated message deserialization |
| ConsoleLogger | Configurable logger for build output |
| Error types | ConfigLoadError, FileReadError, MessageParserError, etc. |
Known Limitations
entryStrategy: "graph"may copy unselected declarations from selected entry files because it treats selected files as graph roots. Use the defaultsymbolsstrategy for strict public/internal splits.- Generation failures are not fully atomic yet and may leave partial selected output after
BoundaryViolationError. - Decorator namespace imports such as
Contracts.ContractCommandare not matched. - Automatic multi-hop tracing through arbitrary local re-export chains is intentionally not automatic. Use direct named imports from trusted contract packages or a trusted integration configuration.
See Also
- @hexaijs/core - DomainEvent and Message base classes used by contracts
- @hexaijs/plugin-application-builder - Companion plugin for handler registration
