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.
The decorators
Section titled “The decorators”@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.
Registering: extend, decorate, list
Section titled “Registering: extend, decorate, list”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:
@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 workexport 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.
The boot invariants
Section titled “The boot invariants”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 injects a function
Section titled “A determination injects a function”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.