name: platform-cookbook
description: Phase-3 recipes and troubleshooting for Designer-generated code now that the aggregate tree is hermetic — Event transport authored in a Process node (the generated <Agg>EventRouter.php is hermetic; Kafka/RabbitMQ/Redis/HTTP-webhook/in-process publish from a node body), seven recipes (VO in a Process node, Domain Service, new aggregate op vs. new Process, Event to Kafka, self-contained Process input, Response shapes per operation, bulk read list→ids→get{Agg}ByIds), troubleshooting table (ClassVersion misses, hermetic-tree edits lost, @node-id body-preserve, R5 routing-safety, V6 cross-BC, listener exceptions).
zone: post-active
persona: C
prerequisites: [platform-implementation]
next: []
Layout context. Since the Platform-removal + Flow→BC refactors (2026-06-08) the aggregate tree is hermetic and Platform-free. Aggregate code lives under
{BC}/Aggregate/{Agg}/(this skill abbreviates that directory as{Agg}/) — the whole tree is ForceOverwrite, never edited (V1). The single developer surface is the BC-level Process scope{BC}/Process/{Name}/(sibling of{BC}/Aggregate/), reached via$bc->process(). Every recipe below lands developer code there, never inside the aggregate. Seeplatform-implementation§1–§2.
1. Event transport — authored in a Process node
The generator emits the base router at {Agg}/Event/<Agg>EventRouter.php (namespace <Domain>\<BC>\Aggregate\<Agg>\Event, ForceOverwrite). It is a plain class <Agg>EventRouter carrying one empty protected function on<Event>(EventListenerRegistryInterface $registry): void stub per Domain Event (body // configure transport), each preceded by a channel-key comment ({ChannelPrefix}.{ChannelSuffix}) as a topic / routing-key suggestion. The Domain facade wires it directly into eventDispatcher():
protected function eventDispatcher(): EventDispatcherInterface|false|null
{
return (new EventDispatcherHandler())(
new CounterEventRouter()
);
}
The router is hermetic (ForceOverwrite) — you never fill its bodies; the next build truncates them (V1). Transport is therefore authored where developer code is allowed: a Process node. Model a Process whose orchestrator runs the aggregate command, then publish the events it produced from a downstream custom node. The aggregate command records its events on the response ($this->result()->addEvent($event, EventScope::Internal) in the generated handler) — the handler only collects, it does not dispatch — so the node reads $response->getEvents() and hands each to a transport via handle(). Note getEvents(?EventScope) is context-keyed (array<string, array<int, object>>), so flatten it before iterating: array_merge(...array_values($response->getEvents())) (or a nested foreach). (Phase A: all aggregate events are Internal; the „verkünden" switch / EventScope::Domain arrives in Phase B.)
Event payload identity (X-3). <Agg>{ChildEntity}Added events carry the affected child's business identifier (<childIdentifier>) when G4 is satisfied — otherwise the internal PK (same rule as the Command response, see Recipe 6). Wire your listener against the business key whenever you can; the internal PK is meaningful only inside the aggregate and changes on rebuild scenarios where data is reseeded.
Publish from a Process node ({BC}/Process/<Name>/Command/Handler/Action/<NodeClass>.php, extends <Domain>Context). The node runs after the node that invoked the aggregate command; it reads the command's events off the previous step and publishes them. The reusable part is the handle() call into the transport package — never new a publisher/client (V2/V3).
Kafka / RabbitMQ / Redis via jardisadapter/messaging:
public function __invoke(WorkflowContextInterface $context): WorkflowResultInterface
{
/** @var DomainResponseInterface $response */
$response = $context->getPrevious()->getData(); // the aggregate command's response
foreach (array_merge(...array_values($response->getEvents())) as $event) {
$this->handle(MessagingService::class)
->publish('meterdevice.counter.counter.created', $event);
}
return new WorkflowResult(WorkflowResult::ON_SUCCESS, []);
}
HTTP webhook via jardisadapter/http:
foreach (array_merge(...array_values($response->getEvents())) as $event) {
$this->handle(HttpClient::class)->post($webhookUrl, ['json' => (array) $event]);
}
In-process (projection, audit trail) — also a node body, calling the projector through handle():
foreach (array_merge(...array_values($response->getEvents())) as $event) {
$this->handle(CounterProjector::class)->onCreated($event);
}
Rules:
- Never
newpublishers / clients inside the node — alwayshandle()(V2 / V3). - Never fill the generated
<Agg>EventRouterbodies — the tree is hermetic (V1); the router's channel-key comments are documentation for the topic names, nothing more. - A node runs synchronously in the workflow. Long operations → enqueue and let a consumer pick up, the node only hands off.
- A throwing node flips the process response per its
ON_FAILrouting (platform-workflow§5). Wraptry/catchinside the node only if "event delivery must not fail the process". - Tenant / feature-flag transport variant: because ClassVersion can resolve any generated class, a
v{N}/Event/<Agg>EventRouter.phpnext to the baseline is the escape hatch for a per-version router — but version creation is out of current scope (PRD P6); prefer the Process node (platform-versioning§1).
2. Phase-3 cookbook
Paths assume BC MeterDevice\Counter, aggregate Counter. Aggregate code lives under {BC}/Aggregate/{Agg}/ (hermetic, never edited); all developer code lands under {BC}/Process/<Name>/ — node bodies in Command/Handler/Action/ (@node-id body-preserve), VOs in ValueObject/, Domain Services in Service/, optional reads in Query/, repos in Repository/.
Recipe 1 — VO used for validation (in a Process node)
The aggregate's generated Hydrate/Build pipeline is hermetic — you cannot inject a VO into it. Validate the domain concept in a Process node that runs before (or instead of) the aggregate command. Author the VO in the Process scope:
VO {BC}/Process/CounterChange/ValueObject/ObisCode.php:
namespace MeterDevice\Counter\Process\CounterChange\ValueObject;
final class ObisCode
{
public function __construct(public readonly string $code)
{
if (!preg_match('/^\d+-\d+:\d+\.\d+\.\d+\*\d+$/', $code)) {
throw new \InvalidArgumentException("Invalid OBIS: {$code}");
}
}
}
Use it from a custom-node body ({BC}/Process/CounterChange/Command/Handler/Action/ValidateNewValue.php):
public function __invoke(WorkflowContextInterface $context): WorkflowResultInterface
{
/** @var CounterChange $cmd */
$cmd = $this->payload();
$this->handle(ObisCode::class, $cmd->obis); // throws on bad input → ON_FAIL routing
return new WorkflowResult(WorkflowResult::ON_SUCCESS, []);
}
handle() constructs the VO (V2/V3 — never new). A bad OBIS throws and the node's ON_FAIL transition takes over. For a tenant-specific variant of any generated class, see the v{N}/ escape hatch (platform-versioning §1).
Recipe 2 — Domain Service for external lookup
Author the Service in the Process scope ({BC}/Process/CounterChange/Service/ResolveMeterLocationName.php):
namespace MeterDevice\Counter\Process\CounterChange\Service;
final class ResolveMeterLocationName
{
public function __construct(private readonly HttpClientInterface $http) {}
public function __invoke(string $meterLocationIdentifier): string
{
$response = $this->http->get("/meter-locations/{$meterLocationIdentifier}");
return (string) ($response['name'] ?? $meterLocationIdentifier);
}
}
Call it from a custom-node body via $this->handle(ResolveMeterLocationName::class, $cmd->meterLocationIdentifier). V9: the service reads only, no persistence — persistence stays in the aggregate command the process invokes.
Recipe 3 — A new operation: aggregate command/query vs. BC-level Process
There is no hand-edit slot on the aggregate; the route depends on what kind of operation it is.
Case A — a new aggregate command or query (mutates/reads this one aggregate's state). Re-model it in the Aggregate Designer (Aggregate.yaml) and rebuild. The Generator regenerates the Command/Query DTO + handler + validator tree and exposes the operation inline on the aggregate facade {Agg}/{Agg}.php:
$bc->counter()->deactivateCounter($dto); // generated, reached via $bc->{agg}()
No method is hand-written and nothing under {Agg}/ is edited — the facade is fully regenerated (no @flow-id, no body merge).
Case B — a BC-level operation or one that coordinates several aggregates. Model a Process in the Process Designer. The Generator emits, under {BC}/Process/<Name>/:
Command/<Name>.php— the self-contained input DTO (readonly, its owninput.fields— Recipe 5). Namespace:…\Process\<Name>\Command\<Name>.Command/Handler/<Name>Handler.php— the Workflow orchestrator (__invoke()+config()building the full graph). ForceOverwrite — regenerated, not edited. Namespace:…\Process\<Name>\Command\Handler\<Name>Handler.Command/Handler/Action/<NodeClass>.php— one node stub per node. Custom nodes areCreateIfNotExistswith an@node-idmarker; the body is yours and survives rebuilds. Namespace:…\Process\<Name>\Command\Handler\Action\<NodeClass>.- A thin-dispatch method on the hermetic
{BC}Processfacade:return $this->context(<Name>Handler::class, $in, $version)();— reached via$bc->process()->{name}($dto).
The segments Query/, Repository/, and Service/ are not emitted by the Generator — they are developer-authored as needed (KI-/Dev-owned).
Your logic lives in the custom-node bodies. The process has no aggregate ownership and invokes whichever aggregate commands/queries it needs via $this->context(...) / $this->handle(...) from a node.
Recipe 4 — Event to Kafka (end-to-end)
A concrete instance of §1. Model a Process that (a) invokes the aggregate command in one node, then (b) publishes its events in a downstream node:
// {BC}/Process/CounterChange/Command/Handler/Action/PublishCreated.php
public function __invoke(WorkflowContextInterface $context): WorkflowResultInterface
{
/** @var DomainResponseInterface $response */
$response = $context->getPrevious()->getData();
foreach (array_merge(...array_values($response->getEvents())) as $event) {
$this->handle(MessagingService::class)
->publish('meterdevice.counter.counter.created', $event);
}
return new WorkflowResult(WorkflowResult::ON_SUCCESS, []);
}
Test via the EventCollector fake (see rules-testing §6) plus a MessagingService fake asserting the published payload. Never edit the hermetic <Agg>EventRouter (§1).
Recipe 5 — A Process input is self-contained
A Process DTO declares its own input and never extends an aggregate Command/Query DTO. The former input.extends: platform:<Name> reference was removed in the Flow→BC refactor (a process has no aggregate ownership). The Designer carries the input as input.fields with Option-1.5 type syntax (?type = nullable, = <literal> = default), and the Generator materialises a standalone readonly DTO:
namespace MeterDevice\Counter\Process\CounterChange;
readonly class CounterChange
{
public function __construct(
public string $identifier,
public int $newValue,
public ?string $note = null,
) {}
}
If a process needs data that lives on an aggregate, it does not inherit a DTO — it runs the aggregate query/command from a node ($this->context(...)) and reads the response. The merged-DTO / platform: parent concept no longer exists; an unknown field in input.fields is a hard build error (no silent fallback).
Recipe 6 — Response shapes per operation (X-1)
The Generator emits a fixed, minimal setData(...) payload per use-case kind — never the full aggregate (CQRS: Command mutates with identity-only echo, Query reads with projected graph). Layout below for Counter (root identifier identifier).
| Use-case kind | Generated setData(...) shape |
|---|---|
| Query ById / By{UniqueKey} (and hand-modelled single variants) | ['counter' => $projected[0] ?? null] — one projected nested scalar tree or null (see Query projection below) |
| Query ByIds | ['counter' => $projected] — list<Akte>, no [0] collapse; missing ids → partial result, empty ids → [] |
| Create | ['identifier' => $handler->getData()->getIdentifier()] — root business key only |
| Set{Child} / Add{Child} | ['identifier' => …, '<childIdentifier>' => …] — root key from cmd-DTO + affected child key resolved via the aggregate walk |
| Update (root scalars) | ['identifier' => $cmd->getIdentifier()] — pure echo of input identity |
| Remove | ['identifier' => $cmd->getIdentifier()] — root identity only |
| Remove{Child} | ['identifier' => $cmd->getIdentifier()] — Remove{Child} skips the child key; only the root identity is echoed |
Substitute the actual root identifier name (e.g. counterId, meterNumber) for identifier where the aggregate uses a different business key. Child responses use the child's business identifier (<childIdentifier>, e.g. counterGatewayId).
Business-key resolution (G4 / X-2). The Generator picks the root identifier by walking the entity for a Single-Column-Unique-Index on a NOT-NULL string column. If exactly one such column exists, that is the business key and surfaces in the response. If none exists, the response falls back to the internal int PK property (e.g. counterGatewayId: int for a keyless counterGateway child — not a defect, the only available identity). If multiple ambiguous candidates exist (X-2: two NOT-NULL-unique-string columns), the Build aborts — model an explicit single business key in the Schema instead of letting the response shape become non-deterministic.
Query projection. The Generator emits per aggregate a {Agg}/FieldMap.php (ForceOverwrite — one method pair {entity}Columns() / {entity}Fields() per entity of the aggregate). FieldMapper::fromAggregate($row, $mapEntity, '<rootKey>') walks the entity map, strips internal PK columns where a business key exists (G4) — except the root entity, whose PK is deliberately re-added as the leading read field 'id' (normalised name; real PK column from the schema). The projected Akte therefore always carries the root id — the internal handle the ById/ByIds read base and the bulk-read recipe rely on; child entities stay id-free. It further strips internal FK columns entirely (the nesting replaces them), collapses pure-join tables (F3.1: tables that exist only as two FKs plus a composite PK — they disappear into the parent-child relation in the projected tree), and keeps DateTimeImmutable blade values inert — JSON/CLI serialization is the caller's job (G5).
Command response never carries domain state — only the identifier(s) the caller needs to address what just changed (event-sourcing / correlation). For the full state after a write, the caller issues the matching read-base query — get{Agg}By{UniqueKey} with the echoed business key, or get{Agg}ById (CQRS).
Adding a custom field. The setData(...) block sits inside the generated, hermetic operation __invoke() body — you do not edit it (V1). To enrich a response, run the aggregate query/command from a Process node, then add fields in the node body before returning ($this->result()->addData('extra', $value) — Decorator at process level, see platform-implementation §4).
Recipe 7 — Bulk read: list → ids → full Akten
Every aggregate facade carries the uniform read base get{Agg}ById / get{Agg}ByIds / get{Agg}By{UniqueKey} (catalog: platform-implementation §1), and both the auto-list items and the projected Akte lead with the root id. Loading "all matching Akten" is therefore three lines, identical across aggregates:
$list = $bc->counter()->counterList($filter); // filtered flat list
$ids = array_values(array_unique(array_column($list['items'], 'id')));
$akten = $bc->counter()->getCounterByIds(new QueryCounterByIds(ids: $ids)); // ['counter' => list<Akte>]
Edge behaviour is plain IN semantics (no new mechanics underneath): empty ids → [] · duplicates → one Akte · missing ids → partial result without error · no order guarantee — match per id, which every Akte carries. There is no generated unique-key set form (getCounterByIdentifiers does not exist): a key-set lookup is a hand-modelled Source query (the array-parameter pattern exists, e.g. CounterByMeterLocation).
Recipe 8 — Sub-process node: calling another process synchronously
A sub-process node is a typed Dev-Stub (CreateIfNotExists + @node-id body-preserve) — not a No-Op. The Generator emits the full skeleton on first build; subsequent builds preserve the developer body unchanged.
Generated skeleton (example: main process CounterChange, sub-process node NotifyOps calling SendOpsNotification):
// {BC}/Process/CounterChange/Command/Handler/Action/NotifyOps.php
// @node-id 2a48bb04
public function logic(WorkflowContextInterface $context): array
{
$in = new SendOpsNotification(
counterId: $this->counterId($context), // werfender Resolver
reason: $this->reason($context), // werfender Resolver
);
$res = $this->context(SendOpsNotificationHandler::class, $in)();
$events = [];
foreach ($res->getEvents(EventScope::Domain) as $subEvents) {
foreach ($subEvents as $e) {
$events[] = $e;
}
}
return [
'status' => $res->isSuccess() ? WorkflowResult::ON_SUCCESS : WorkflowResult::ON_FAIL,
'data' => [EventScope::Domain->value => $events],
];
}
// --- werfende Resolver (Dev füllt die Werte) ---
protected function counterId(WorkflowContextInterface $context): mixed
{
throw new \RuntimeException('Sub-DTO-Feld counterId fuellen: ' . self::class);
}
protected function reason(WorkflowContextInterface $context): mixed
{
throw new \RuntimeException('Sub-DTO-Feld reason fuellen: ' . self::class);
}
What the developer does: replace each werfenden Resolver with the real value from $context (e.g. return $this->payload()->counterId;). The $in constructor call, the $res = $this->context(…)() call, the status mapping, and the event bubbling are generated — do not touch them.
Event bubbling (flat, Domain-scope only): EventScope::Domain events from the sub-DomainResponse are collected flat into the data return array. The main-process orchestrator harvests them identically to events from any other node (getChain() → $data[EventScope::Domain->value] → addEvent(…, Domain)). Internal events of the sub-process stay sub-process-internal (they are not returned).
Routing (onFail): add an onFail edge from the sub-process node in the Process Designer — it is automatically picked up by statusSetFromEdges in the node's generated config(). onFail = the sub-process run broke (exception or InternalError response); a fachliches Verdikt (true/false) is data and is routed via a downstream decision node.
subprocessOnly flag: a process that is only called as a sub-process (never directly via $bc->process()) should have subprocessOnly: true in its YAML (UI toggle „In API sichtbar", default ON). This suppresses the thin-dispatch method on the {BC}Process facade — the process DTO, orchestrator, and node stubs are always generated regardless of the flag.
Rules:
- Never
newthe Sub-Handler directly — always$this->context(SubHandler::class, $in)()(V2 / V3). - Never return the
DomainResponseobject of the sub-call upstream — return the flat['status' => …, 'data' => […]]array (the orchestrator's harvest loop expects this shape). - The sub-process node body survives rebuilds (unlike ForceOverwrite nodes) — keep the
@node-idmarker intact.
3. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
LogicException: Cannot resolve ClassName |
BC vs. Aggregate segment swapped in namespace | Namespace is <Domain>\<BC>\Aggregate\<Agg>\… — BC and Aggregate are two segments even when they share a name (the Aggregate/ segment sits between them) |
Edit under {BC}/Aggregate/{Agg}/ gone after rebuild |
The whole aggregate tree is hermetic (ForceOverwrite, V1) — every build truncates and rewrites it; there is no override slot inside it | Move the behaviour to a Process ({BC}/Process/<Name>/Command/Handler/Action/), re-model the aggregate in the Designer, or (tenant variant) author a v{N}/<Class>.php next to the baseline (platform-versioning §1) |
on<Event>() edit gone after rebuild |
Bodies were filled in the hermetic <Agg>EventRouter.php |
Never edit the router — author transport in a Process node that publishes $response->getEvents() after the aggregate command (§1) |
Versioned override (v2/…) ignored |
Domain facade isn't passing 'v2' as $version, or a needed ClassVersionConfig fallback entry is missing |
Thread the version through the call ($bc->{agg}()->createCounter($dto, 'v2')) or set a version() default on <Domain>.php; the variant must sit at {Agg}/…/v2/<Class>.php (immediate neighbour of the baseline) — platform-versioning §1 |
| Process node body lost after rebuild | The custom node lost its @node-id marker, or the file had broken syntax so the body-preserve merger could not parse it |
Keep the generated @node-id DocBlock marker intact; fix the parse error. The merger regenerates the node header but preserves the body keyed by @node-id. (The Designer's "Force" build path deliberately overwrites a node body.) |
| Process node not invoked though it's in the graph | R5-Routing-Safety: the node isn't registered via addNode(), or the returned ON_* status has no transition in the current node |
Every handler referenced in ->onSuccess()/onFail()/… must be declared as its own ->node(...); add the missing status to the routing — platform-workflow §5 |
Error: Cannot instantiate abstract class / "Service X not in container" |
Direct new bypassing handle() (V2 / V3) |
Replace with $this->handle(X::class, ...) from inside the node |
Cannot import OtherBC\... review blocker |
V6 violation (cross-BC import) | Add a Domain Service in the Process scope and call the other BC via handle() |
Process tries to extend an aggregate DTO (extends platform:…) |
Removed feature — a process input is self-contained (Recipe 5) | Declare the fields the process needs in input.fields; fetch aggregate data by running its query from a node |
getData() empty after addData() in a node |
The node returned before augmenting $this->result(), or replaced the payload |
Read aggregate data, then addData(...)/setData(...), then return |
| Process node throws → whole process fails | Default: an uncaught node exception routes to ON_FAIL (or bubbles to 500 if unrouted) |
Wrap the node body in try/catch only if its failure must not fail the process; otherwise add the ON_FAIL transition — §1, platform-workflow §5 |
| Sub-process node body overwritten after rebuild | Sub-process node lost its @node-id marker, or the file was built with an older Generator version (formerly ForceOverwrite No-Op) |
Keep the @node-id DocBlock marker intact; if the file is an old No-Op, delete it — the next build emits the typed Dev-Stub fresh (Recipe 8) |
| Sub-process werfender Resolver throws at runtime | Expected — the resolver is a placeholder until the developer fills in the real value from $context |
Replace throw new \RuntimeException(…) in each resolver with the real value (e.g. return $this->payload()->counterId;) |
Sub-process Domain events missing in main response |
The sub-process node returned Internal events under EventScope::Domain->value by mistake, or the orchestrator loop wasn't updated |
The stub returns [EventScope::Domain->value => $events]; the orchestrator harvests $data[EventScope::Domain->value] — both use the enum value string; check that EventScope is imported in both files |
Process doesn't appear on $bc->process() facade |
subprocessOnly: true is set — by design |
The process is only callable as a sub-process node; use $this->context(Handler::class, $dto)() from another node; or unset the flag if the process should also be a public API entry |
Anchors
platform-implementation(hermetic aggregate layout, the three customization surfaces, prohibitions, decision tree).platform-versioning(ClassVersion resolution —LoadClassFromSubDirectory, per-classv{N}).platform-workflow(Workflow-Engine API used by the Process orchestrators + node routing referenced above).adapter-messaging,adapter-http,adapter-eventdispatcher(event-transport recipes).rules-testing(EventCollector fake for Recipe 4).