ZuloOne
English
Sign in
Menu

⛓️ Chain of Command

Updated

How several event handlers of one object, and several implementations of one IFoo, call each other through next(...). Short recipe: Wrap with Chain of Command.

← Developer wiki

The same text opens from the ⓘ button on the Chain of Command strip in the studio (guide key script.chain).

Why this exists#

One document or dictionary can have several handlers from different models: stock (Inventory), cost (Costing), the tenant. This is neither class inheritance nor a "left-to-right subscription". It is an onion: the top layer starts first, next(...) goes down and returns the result of the lower link.

Code after next() sees what the lower ones wrote (stock, GL) — owner work, then each extension, in layer order.

Transaction scripts on one subtype are still a horizontal list (stock + cost), not a single onion. The CoC vertical is for when you need to adjust an already existing tx / command / driver of another model: next(...), not a second button. New links come only from the "Add yours on top" strip.

Services sharing one contract name IFoo are the same onion: GetService<IFoo>() starts from the top layer, next<T>(...) / nextAsync<T>(...) goes down. A single implementation is not wrapped.

How to write it#

[ExtensionOf("GoodsIssue")]                       // a handler of somebody else's object declares itself
public partial class GoodsIssueCostingEventHandler : TypedDocumentEventHandler<GoodsIssue>
{
    public override async Task<EventResult> OnAfterPostAsync(GoodsIssue header, EventContext context)
    {
        var prior = await next(header, context);
        if (!prior.Success) return prior;
        // here header and the database are already as the lower layer left them
        // header.Lines (and the other table parts) are already on the object — the same graph that went into Save
        return EventResult.Ok();
    }
}

The owner (the handler in the object's own model) is written with no attribute and no next:

public partial class GoodsIssueEventHandler : TypedDocumentEventHandler<GoodsIssue>
{
    public override Task<EventResult> OnAfterPostAsync(GoodsIssue header, EventContext context)
        => Task.FromResult(EventResult.Ok());
}

The rules:

  1. Pass the same arguments into next that the signature received (header, context, record, isNew, context, …). After the lower link, the typed next(record, …) copies the bag back onto the same instance — work after next() sees their fields. In the editor the parameter hint (IntelliSense) takes <param> from the XML docs of the hook and of next().
  2. A missing next on a link above the owner is compile error ZOCOC001. The lower handlers of that hook will not run. The owner does not call next — it is link 0, there is nobody below.
  3. A handler on an object owned by ANOTHER model must declare itself with the class attribute [ExtensionOf("ObjectName")], otherwise ZOCOC004. The name is checked against the script envelope: rename the object and you see an error, not a handler that quietly detached. The attribute on a handler of YOUR OWN model is ZOCOC004 as well ("it IS the owner").
  4. To stop the chain on purpose — [Replace] on the method, with no next.
  5. A second next in the same override throws.
  6. An empty class with no override is normal: the hook does not take part in the chain. A field on a dictionary card, a field in a document header and a cell of a table part line call live evaluate (POST /api/events/dictionary/field / document/field / tablepart/field) — the same OnValidateField / OnFieldChanged, the same next onion.
  7. Class names are unique across the whole workspace: the owner is GoodsIssueEventHandler, Costing's is GoodsIssueCostingEventHandler, the tenant's has a suffix of its own.

override goes against the platform's virtual (TypedDocumentEventHandler<T>), not against another handler. The classes are neighbours, not Costing : Inventory.

Where it is in the interface#

One and the same strip: the owner on the left, the higher layer on the right. The right one starts first. The arrows are the direction of next(), leftwards toward the owner.

Inventory  ←  Costing  ←  Tenant
  bottom                top (starts first)

For events the owner is fixed structurally: it is the handler in the object's own model, and it is always the leftmost one, even if the extension is declared on the same layer. Order by layer and name and a foreign layer-2 extension SalesGLEventHandler ends up to the left of the owner SalesInvoiceEventHandler (Sales, layer 2) simply because "SalesG…" < "SalesI…".

The strip is drawn straight from GET /api/metadata/scripts/event-chain, which orders through the same EventChainOrder the runtime uses, so the numbering on it is the execution order. Nothing in the front end re-sorts the answer.

What Where to go To add your own To check
Dictionary events Dictionaries → a row / the designer → Events (or "Open event handler") "Add yours on top" on the strip; saving creates a handler in the working model with [ExtensionOf("…")] Check in the editor — ZOCOC001 / ZOCOC004
Document events Documents → Events the same the same
Table part events Table part types → Events the same the same
Register events Registers → Events (accumulation ones) the same the same
The IFoo service Services → a row (the "Chain" column, if the name exists on several layers) → the card or the script "Add yours on top": the same name, next<T> stubs in the working model (a strictly higher layer) Check on the script — ZOCOC002; a red triangle on the link if the registry rejected it
Totals driver Registers / drivers → the script slot "Add yours on top" on this driver's strip. A second driver cannot be put beside it Check — ZOCOC003
Command The command card / the script slot "Add yours on top" on this command's strip. Do not start a second command "with the same meaning" Check — ZOCOC003
One owner's tx The slot of a specific tx, not the subtype's list "Add yours on top" on this script's strip ≠ "a new tx on the subtype" Check — ZOCOC003
The extensions registry /extensions the "Chain" column — the same links as on the target object; a click opens the script your own link has landed

The working model must be a layer above the owner. Two links on one layer are not allowed — a tie throws.

The horizontal list of tx on a subtype (executionOrder) is not this strip. Stock and cost do not glue into one onion.

Services — the same IFoo on different layers#

The same service name (the metadata name → the contract I{Name}) in two models makes neighbours, not heirs. The lower layer owns the interface; the upper one implements the same IFoo in full.

public partial class Adder
{
    public long Add(long a, long b) => next<long>(a, b) + 1;
}

The rules:

  1. Pass the method's arguments into next<T> / nextAsync<T>. The type T is the return type (for Task<T> — the inner T).
  2. The owner on the lower layer does not call next — there is nobody below.
  3. A missing next on an extension is ZOCOC002. The registry does not register such an extension, and GetService<IFoo>() stays on the inner one.
  4. To stop the chain on purpose — [Replace] on the method.
  5. A second next in the same call throws. next on the very lowest one is "no inner service".
  6. Two implementations of one IFoo on the same layer are a registry error; there is no winner.
  7. A single implementation — no proxy.
  8. A platform contract (IQuantityConverter): the chain runs by layers; a tie on one layer still throws.
  9. The class name still equals the service name. Inside the workspace's single IDE project, put the extension in a namespace of your own, otherwise two class Adder will collide.
  10. The chain's proxy is a collectible IFoo_Chain (not DispatchProxy). A script contract out of the collectible ALC and a platform IFoo travel the same path: two implementations across layers and GetService<IFoo>() hands back the chain.

GetService<IFoo>() and Run in the designer on an extension link both see the chain with that link on the outside.

A driver, a command, a tx — the same next#

The owner does not have to to call next — it writes the posting entries / ExecuteAsync / the engine's base.. An extension is obliged to:

public override async Task<CommandResult> ExecuteAsync(CommandContext context)
{
    var prior = await nextAsync<CommandResult>(context);
    if (!prior.Success) return prior;
    // the document is already past the owner
    return prior;
}

A missing next on an extension is ZOCOC003. [Replace] stops the chain on purpose. A tie of two links on one layer is an error.

Renaming an object detaches the whole chain#

Handlers find their object by name (objectName in the script envelope), not by objectMetaId. Transaction scripts are the other way round — by their binding to the subtype, that is, by metaId.

Rename a document and the metaId wiring updates, while the envelopes keep the old name. The document continues being posted — the stock and revenue movements happen — and at the same time silently stops doing everything the handlers did.

SalesInvoice → SalesRealization is the shape: leave the old name in the envelopes and not one of the invoice's handlers is called any more.

What does not fire What is lost
OnBeforePost the legal entity is not stamped, TaxRateApplied stays 0
OnAfterPost no TaxCalculation is born → no VAT, no GL, no loyalty points
OnBeforeSave the customer's PaymentTerm and Contact are not copied

How to tell "the handler was not called" from "the handler crashed". Put return EventResult.Cancel("PROBE") as the first line. It depends on no service at all, so it cannot break by itself: if the posting went straight past it, the handler is not hooked up. Probing with a logger is not allowed — GetService is able to throw, and a probe that crashes is indistinguishable from one that was never called.

The gate .github/scripts/check_script_bindings.py in the workspace checks every envelope's objectName against the object's real name, looked up by its objectMetaId.

What is not CoC#

Mechanism How to extend it
Events a neighbouring class + [ExtensionOf("…")] + next(...)
A model service IFoo (Core's included) the same name in your own model + next<T>(the same arguments)
A driver / a command / one tx the CoC vertical: next / nextAsync, the "Add yours on top" strip
A Core kernel driver (StockBaseTotalDriver, …) a wrap with next(), not a partial class named after the engine
Several tx on one subtype a list by executionOrder (a horizontal; not this strip)
The platform's IDataService / IDocumentManager / ISqlService not wrappable from the workspace

Checking#

  • Compiling the model: POST /api/metadata/models/compile — there must be no ZOCOC001 / ZOCOC003 / ZOCOC004. Compile-all writes the missing [ExtensionOf("…")] onto a handler of another model's object by itself and saves the script; Check in the editor still shows ZOCOC004, so that the rule stays visible to the author.
  • Services: POST /api/services/rebuild and Check on the extension's script — there must be no ZOCOC002.
  • Platform tests: EventChainOfCommandTests, ServiceChainOfCommandTests, ServiceCoCTests, ExecutableCoCTests, CommandChainOfCommandTests, TotalDriverChainOfCommandTests, TransactionChainOfCommandTests, ChainOfCommandAnalyzerTests.
  • Skills: zuloone-extend §2 / §2b / §2c–§2e, zuloone-new-service, zuloone-new-document §3, zuloone-new-command.
  • The workspace contract: docs/WORKSPACE.md §8.1 / §8.7.

Where next#

Was this page helpful?