Skip to content

Registration

A domain plugin declares its business objects and extensions as values on @Plugin, and registers everything that runs as a decorated provider: a class with constructor injection, listed in the plugin’s providers, found by NestJS’s own DiscoveryService.

Determinations, validators, actions and functions are swept into meta: each one’s declaration reaches the browser through GET /api/meta, and its code stays on the server. PDF templates, workflows and steps have no meta presence at all.

Operations are invoked through the Actions / Functions pair from @onerp/meta on every plane — client.operations in the browser, ActionService / FunctionService in-process on the server, each the dispatch seam for its kind. Both look the operation up in MetaService.current(); the BusinessObjectManager stays records-only.

@Determination, @Validator, @Action, @Function, @PdfTemplate, @Workflow, and @Step register seven distinct concepts — a derived value, a check, a write, a read, a document rendering, a durable process, a wrapper around work another module owns. What they share is mechanics, not meaning: each carries a class factory whose returned abstract base a domain class extends, and each is a decorator from DiscoveryService.createDecorator() marking the subclass for one boot sweep. Registering any of them is the same three moves — extend, decorate, list.

Decorator Base Method Collected by
@Determination Determination.on(Schema, config) compute PluginRegistry
@Validator Validator.on(Schema, config) validate PluginRegistry
@Action Action.on(Schema, config) / Action.root(config) run (isAvailable optional) PluginRegistry
@Function Function.on(Schema, config) / Function.root(config) run PluginRegistry
@PdfTemplate PdfTemplate.on(Schema) render TemplateRegistry
@Workflow Workflow.on(triggers, config) run WorkflowExplorer
@Step Step.of(Pipeline) run PipelineRunner

All seven decorators are markers: they carry no config, because the base the factory built already carries the declaration — a rule’s name, inputs and outputs; an operation’s name, subject, parameters and return shape; a template’s business object; a workflow’s id and triggers. That is what lets the type system see it, since a decorator cannot contribute to a class’s type. On the base it types callers (Params<Pricing> against an operation’s class), subjects (render receives the template’s own record type), events (a workflow’s run context derives from its triggers — the union, for a multi-trigger workflow), and a step’s input and result, both read off the pipeline it was bound to.

Action and Function are not symmetric. A function’s config requires both params and returns — it always takes arguments and always answers — where an action makes both optional. And only a root function is callable: dispatch and POST /api/functions/:namespace/:name alike look one up by its two-segment name alone, so a Function.on(Schema, …) binding has no route and no caller. Every function today is a Function.root.

The marker goes on the subclass, never the base: Nest’s discovery matches the exact decorated class reference, so metadata on a factory-produced base is invisible to the sweep.

One base has the same extend, list shape and no marker: Rollup.on(Target, { field, from }) (see Rollups). It needs no registry because it is a listener — the base carries the @OnEvent subscription for its source, and the event loader walks the prototype chain, so a method decorated on the base fires for the subclass. A rollup is registered the moment it is listed in providers.

Extend the base the factory returns, decorate the subclass, and list it in the plugin’s providers. Constructor injection works exactly as on any other NestJS service — there is no descriptor object and no manual registration step:

domains/sales/src/sales-order/create-invoice-from-order.action.ts
@Action()
@Injectable()
export class CreateInvoiceFromOrder extends Action.on(SalesOrderSchema, {
name: "createInvoice",
label: "Create Invoice",
returns: { invoice: field.referenceOne("sales/SalesInvoice", { label: "Invoice" }) },
}) {
constructor(private readonly drafts: DraftWorkspace) {
super();
}
async run(
order: BusinessObject<typeof SalesOrderSchema>
): Promise<Returns<CreateInvoiceFromOrder>> {
const invoice = await this.drafts.create(SalesInvoiceSchema, { /* … */ });
return { invoice: invoice.id };
}
}

The base’s abstract run pins both the arity (a bound operation receives its subject first, a root one does not) and the parameter and return types against the config. isAvailable is concrete on the base and returns true; override it to gate on record state. The subject is spelled BusinessObject<typeof Schema> inline, or as the instance type the schema exports beside itself — never a local alias re-deriving it.

An action that declares params annotates run’s second parameter against its own class — Params<TheAction>, the same spelling every caller uses — and one that declares returns annotates the return as Promise<Returns<TheAction>>: a missing return key is an error either way, but an extra key is only caught with the annotation. The last move is one line in the plugin: providers: [CreateInvoiceFromOrder, …].

Pipelines: leaving room for another module

Section titled “Pipelines: leaving room for another module”

@Step is the one of the seven whose target is declared by another module. A module that owns a piece of work calls definePipeline to say a plugin may wrap it; the pipeline object carries nothing at runtime and is itself the identity steps bind to, so a step reaches one by importing it.

// packages/core/src/pdf/render-pdf.pipeline.ts — core owns the work
export const RenderPdf = definePipeline<PdfRequest, Buffer>();
// domains/eu-vat/src/e-invoice/factur-x.step.ts — any plugin wraps it
@Step()
@Injectable()
export class FacturXStep extends Step.of(RenderPdf) {
async run(request: PdfRequest, next: () => Promise<Buffer>): Promise<Buffer> {
const pdf = await next();
/* … attach the EN 16931 XML, re-seal as PDF/A-3 … */
return pdf;
}
}

The owner calls it with the work itself as the innermost handler — LocalPdfRenderer runs every render as this.pipelines.run(RenderPdf, request, async () => renderToBuffer(await template.render(record))), so no caller can produce a document the steps never saw. A step sees the finished result of everything it wraps and may withhold next() entirely, which is how a plugin replaces standard output rather than only appending to it. A pipeline with no step is not an error: the handler is then the whole pipeline, which is what an installation without the wrapping plugin runs. Every pipeline carries at most one step today, so ordering is left to discovery; the day one gets a second step, they need a declared order.

Every collector sweeps once, eagerly, at boot, and every one of them goes through the same sweep(discovery, decorator) — the single place that walks discovery, refuses a non-singleton, and casts the type Nest erased back to the contract the caller named. A registered provider must be a singleton: a request- or transient-scoped one hands discovery a prototype-only shell whose injected fields are all undefined, registering cleanly and failing much later inside a render or a workflow step, so @Injectable({ scope: Scope.REQUEST }) fails at boot, naming it.

What each collector does with the swept list is its own. The keyed ones throw on a duplicate identifier — boot-order last-write-wins must never decide which implementation an identifier resolves to. Composition identifies a rule or a bound operation by its name on its business object and a root operation by its two-segment name, and rejects a repeat in any layer; TemplateRegistry keys by business object type; WorkflowExplorer by workflow id (the workflow engine knows a function only by its id). PipelineRunner has no key — steps are a list, so it appends every one it finds, and checks each actually implements run, which declaration emit erases from the abstract base.

A determination or validator reaches a function the way any provider reaches a service — by constructor injection — and calls its run directly:

constructor(readonly pricing: Pricing) {
super();
}
// …
const result = await this.pricing.run({ productId: item.product, … });

The call is in-process and skips FunctionService, so no permission check and no parameter parsing run. Rules run in a read-only unit of work, so an injected action — or anything else that stages a write — throws.