---
name: platform-cookbook
description: Phase-3 recipes and troubleshooting for Designer-generated code — Event transport via `<Agg>EventRouter.php` (Kafka/RabbitMQ/Redis/HTTP-webhook/in-process), six recipes (VO in Hydrate, Domain Service, new Command/Query, Event to Kafka, Flow-DTO `input.extends`, Response shapes per operation), troubleshooting table (ClassVersion misses, lost overrides, R5 routing-safety, V6 cross-BC, listener exceptions).
zone: post-active
persona: C
prerequisites: [platform-implementation]
next: []
---

### 1. Event transport — `<Agg>EventRouter.php`

The generator emits the base router at `<Domain>/<BC>/<Agg>/Platform/Event/<Agg>EventRouter.php` (ForceOverwrite, typed on `EventListenerRegistryInterface`). It carries one empty `protected function on<Event>(EventListenerRegistryInterface $registry): void {}` per Domain Event of this aggregate, with a channel-key comment (`{ChannelPrefix}.{ChannelSuffix}`) in front of each method as a topic / routing-key suggestion.

**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.

To wire transport, create a baseline override at `<BC>/<Agg>/Event/<Agg>EventRouter.php` that **extends** the Platform class and fills the `on<Event>()` bodies. For tenant- or feature-flag variants, mirror the same file under `<BC>/<Agg>/v{N}/Event/<Agg>EventRouter.php`. The `LoadClassFromExtensions` reader picks the highest-priority router according to the active `version()` (`platform-versioning` §1).

**Domain-facade wiring.** The generated Domain facade overrides `eventDispatcher()` and feeds every aggregate router of the domain (alphabetically by BC, then by Aggregate) **directly** into the `EventDispatcherHandler`:

```php
return (new EventDispatcherHandler())(
    new \MeterDevice\Counter\Counter\Platform\Event\CounterEventRouter(),
    new \MeterDevice\Counter\Gateway\Platform\Event\GatewayEventRouter(),
    // …
);
```

Direct `new` is used here because the `eventDispatcher()` hook runs above `BoundedContext` (where `handle()` lives). Listener resolution inside each `on<Event>()` body still flows through `handle()` as usual. **Kernel platform: no override** — wire manually in your `DomainApp` subclass.

**Kafka / RabbitMQ / Redis** via `jardisadapter/messaging`:

```php
namespace MeterDevice\Counter\Counter\Event;

use MeterDevice\Counter\Counter\Platform\Event\CounterEventRouter as Base;
use MeterDevice\Counter\Counter\Platform\Event\CounterCreated;
use JardisSupport\Contract\EventDispatcher\EventListenerRegistryInterface;

final class CounterEventRouter extends Base
{
    protected function onCounterCreated(EventListenerRegistryInterface $registry): void
    {
        $registry->listen(CounterCreated::class, function (CounterCreated $event): void {
            $this->handle(MessagingService::class)
                ->publish('meterdevice.counter.counter.created', $event);
        });
    }
}
```

**HTTP webhook** via `jardisadapter/http`:

```php
protected function onCounterCreated(EventListenerRegistryInterface $registry): void
{
    $registry->listen(CounterCreated::class, function (CounterCreated $event): void {
        $this->handle(HttpClient::class)->post($webhookUrl, ['json' => (array) $event]);
    });
}
```

**In-process** (projection, audit trail):

```php
protected function onCounterCreated(EventListenerRegistryInterface $registry): void
{
    $registry->listen(
        CounterCreated::class,
        fn (CounterCreated $event) => $this->handle(CounterProjector::class)->onCreated($event),
        priority: 10,   // higher = earlier
    );
}
```

**Rules:**

- Never `new` publishers / clients inside the listener — always `handle()` (V2 / V3).
- Listener runs **synchronously** with dispatch. Long operations → queue consumer, not here. Listener only hands off.
- Listener exceptions bubble to the handler's outer `try/catch` and flip the response to 500. Wrap `try/catch` inside the listener only if "event delivery must not roll back the command".
- Priority ordering: lower = later.

### 2. Phase-3 cookbook

Paths assume `MeterDevice\Counter\Counter`. All files land directly under `{BC}/{Agg}/`, parallel to `Platform/`.

**Recipe 1 — VO used in Hydrate action (baseline override, null setup)**

VO `src/MeterDevice/Counter/Counter/ValueObject/ObisCode.php`:

```php
namespace MeterDevice\Counter\Counter\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}");
        }
    }
}
```

Baseline override `Command/Handler/Action/HydrateCreateCounterEntities.php`:

```php
namespace MeterDevice\Counter\Counter\Command\Handler\Action;

use MeterDevice\Counter\Counter\Platform\Command\Handler\Action\HydrateCreateCounterEntities as Base;
use MeterDevice\Counter\Counter\ValueObject\ObisCode;

final class HydrateCreateCounterEntities extends Base
{
    protected function hydrateCounterRegister(Counter $handler, CommandCounter $counter): void
    {
        parent::hydrateCounterRegister($handler, $counter);
        foreach ($counter->counterRegister as $reg) {
            $this->handle(ObisCode::class, $reg->obis);
        }
    }
}
```

Create file, run test: bad OBIS → `ValidationError` + error message. No `ClassVersionConfig` needed — `handle()` finds the baseline override (stage 5 in `platform-versioning` §1) automatically.

For a tenant-specific variant, put the same file under `{Agg}/v2/Command/Handler/Action/…` and switch it on by overriding `version()` on the Domain facade.

**Recipe 2 — Domain Service for external lookup**

`Service/ResolveMeterLocationName.php`:

```php
namespace MeterDevice\Counter\Counter\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 from a baseline override of a Build action via `$this->handle(ResolveMeterLocationName::class, $dto->meterLocationIdentifier)`. V9: service reads only, no persistence.

**Recipe 3 — New Command next to generated ones**

Two paths depending on whether you reach for the FlowDesigner or write the Use Case by hand.

**Path A — Designer-driven (recommended).** Model the Use Case in the FlowDesigner. The Generator emits:

1. `Command/DeactivateCounter/DeactivateCounter.php` — `readonly` DTO with `identifier`, `deactivatedAt`.
2. `Command/DeactivateCounter/DeactivateCounterHandler.php` — Workflow orchestrator with a single `config(): WorkflowConfigInterface` plus a `try/catch` wrapping the `$workflow(...)` call (flat graph — no IPO-separator methods).
3. `Command/DeactivateCounter/Action/<NodeId>.php` — one CreateIfNotExists Node-Stub per Knoten im flachen `Action/`-Subdir. Signatur fix: `__invoke(WorkflowContextInterface): WorkflowResultInterface`; der Body gehört dem KI-Dev-Loop (Mini-PRD steht als DocBlock am `__invoke`).
4. `<Agg>.php` — an `@flow-id`-tagged stub method `deactivateCounter(DeactivateCounter $in): DomainResponseInterface` is signature-merged onto the aggregate root.

The body of the aggregate-root method is empty stub on first build (`return new DomainResponse(ResponseStatus::Success, [], [], [], []);`); replace it with your dispatch (typically `return $this->context(DeactivateCounterHandler::class, $in)();`) — the Integration-Generator preserves your body on every later build.

**Path B — Hand-written without FlowDesigner.** Drop a method without `@flow-id` directly on `<BC>/<Agg>/<Agg>.php` and fan out into Domain Services + Repository calls:

```php
public function deactivateCounter(DeactivateCounter $in): DomainResponseInterface
{
    return $this->context(MyDeactivateHandler::class, $in)();
}
```

Place the handler under `<BC>/<Agg>/Command/Handler/MyDeactivateHandler.php` (`extends <Domain>Context`, loads via `handle(CounterRepository::class)->…`, mutates via aggregate action, persists, emits event — Response per `platform-implementation` §7 (Constructing DomainResponse)). No registry override needed — the aggregate root *is* the registry.

**Recipe 4 — Event to Kafka**

Create `Event/CounterEventRouter.php` extending the Platform router:

```php
namespace MeterDevice\Counter\Counter\Event;

use MeterDevice\Counter\Counter\Platform\Event\CounterEventRouter as Base;
use MeterDevice\Counter\Counter\Platform\Event\CounterCreated;
use JardisSupport\Contract\EventDispatcher\EventListenerRegistryInterface;

final class CounterEventRouter extends Base
{
    protected function onCounterCreated(EventListenerRegistryInterface $registry): void
    {
        $registry->listen(CounterCreated::class, function (CounterCreated $event): void {
            $this->handle(MessagingService::class)
                ->publish('meterdevice.counter.counter.created', $event);
        });
    }
}
```

Baseline override — `handle()` picks it up when the Domain facade wires the router. Test via `EventCollector` fake (see `rules-testing` §6). For a tenant variant, put the same file under `{Agg}/v2/Event/CounterEventRouter.php`.

**Recipe 5 — Flow-DTO extends a Platform Command/Query DTO (`input.extends`)**

A Designer Flow can declare `input.extends: platform:<Name>` in `<Agg>.yaml` to reuse the Platform-generated Command-/Query-DTO as parent. The generated Flow-DTO emits as:

```php
namespace MeterDevice\Counter\Counter\Command\CounterChange;

use MeterDevice\Counter\Counter\Platform\Command\CounterChange as PlatformCounterChange;

readonly class CounterChange extends PlatformCounterChange
{
    // Only the additive Flow-DTO properties — inherited ones come from Platform.
}
```

Unknown names are a hard build error (no silent fallback). The Flow-DTO is what the Aggregate-Root stub method takes (`{Agg}/{Agg}.php::counterChange(Command\CounterChange\CounterChange $in)`), so transport callers see the union of inherited + additive properties.

For per-property defaults / nullability on the Flow-DTO, the Designer carries `nullable: true` / `default: <literal>` on each property — both flags shape the generated constructor signature. The Flow-DTO constructor materialises the union of inherited (`extends platform:<Name>`) and additive properties; transport callers pass the merged set.

**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 / Get** | `['counter' => $projected[0] ?? null]` — projected nested scalar tree (see Query projection below) |
| **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 `<BC>/<Agg>/Platform/FieldMap.php` (ForceOverwrite). `FieldMapper::fromAggregate($row, $mapEntity, '<rootKey>')` walks the entity map, strips internal PK columns where a business key exists (G4), 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 `Get{Agg}` query (CQRS).

**Override slot.** The `setData(...)` block sits inside the operation `__invoke()` body. To add a custom field, override the orchestrator (`Command/Handler/Action/…`) or wrap with a Domain Service. Do not edit `Platform/`-files — `{Agg}/Platform/` is `ForceOverwrite` and every modification is truncated by the next build (V1).

### 3. Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `LogicException: Cannot resolve ClassName` | BC vs. Aggregate segment swapped in namespace | Namespace is `<Domain>\<BC>\<Agg>\…` — BC and Aggregate are two segments even when they share a name |
| Baseline override at the aggregate root not picked up | Wrong namespace — no `Platform` segment for the override (Platform is generator-only) | `namespace <Domain>\<BC>\<Agg>\<GeneratorSubpath>` (no `Platform`); inside the file, `extends \…\Platform\<GeneratorSubpath>\<Class>` |
| Versioned override (`v2/…`) ignored | Domain facade doesn't return `'v2'` from `version()`, or `ClassVersionConfig` is missing the entry | Override `protected function version(): string` on the `<Domain>.php` facade; ensure `ClassVersionConfig` lists `v2` with its fallback chain — `platform-versioning` §1 |
| `Error: Cannot instantiate abstract class` / "Service X not in container" | Direct `new` bypassing `handle()` (V2 / V3) | Replace with `$this->handle(X::class, ...)` |
| `on<Event>()` edit gone after rebuild | Bodies were filled directly in the Platform `<Agg>EventRouter.php` | Move the body into `<BC>/<Agg>/Event/<Agg>EventRouter.php` extending `…\Platform\Event\<Agg>EventRouter` — generator only rewrites `Platform/` |
| Edit under `{Agg}/Platform/` gone after rebuild | `Platform/` is `ForceOverwrite` — every build truncates and rewrites it | Move the edit to the matching path directly under `{Agg}/` (parallel to `Platform/`), `extends` the Platform class |
| Aggregate-root method body lost after rebuild | Method carries `@flow-id` — the Integration-Generator regenerates signature + DocBlock on every build, but **preserves the body**. If the body is gone, either the merger ran on a hand-edited file with broken syntax (parse failed), or the method's `@flow-id` ID changed (renamed flow → new method, body of the old method got commented out). | Inspect the file for commented-out blocks (orphan handling), copy the body across to the new method. Never rename a flow ID by hand; use the FlowDesigner's rename flow. |
| Aggregate-root method commented out after rebuild | Flow ID no longer in the build (flow deleted in the Designer, fixture removed, …) — the merger marks the method as orphan. | Either restore the flow upstream or delete the commented-out method body manually after copying anything you still need. |
| Hand-written method on the aggregate root vanished | The method had an `@flow-id` tag that the merger no longer recognises (foreign tag content) | Remove the `@flow-id` tag — methods without that tag are dev-only and never touched by the generator. |
| `Cannot import OtherBC\...` review blocker | V6 violation | Add Domain Service, call other BC via `handle()` |
| `getData()` empty after `addData()` in override | Override skipped `parent::__invoke(...)` — parent's `setData` ran against a different `$this->result()` | Always `parent::__invoke(...)` first, then augment |
| Listener throws → command rolls back | Default: listener exception flips response to 500 | Wrap listener body in `try/catch` only if delivery failure must not roll back — §1 above |
| Flow endet ohne Fehler trotz erwartetem Folge-Node | R5-Routing-Safety: Transition-Target ist nicht selbst per `addNode()` registriert, oder der zurueckgegebene `ON_*`-Status hat ueberhaupt keine Transition im aktuellen Node | Builder-Kette pruefen — jeder im `->onSuccess()/onFail()/…` referenzierte Handler muss als eigener `->node(...)` deklariert sein; fehlenden Status im Routing ergaenzen — `platform-workflow` §5 |

### Anchors

- `platform-implementation` (Generated baseline, override targets, prohibitions, decision tree).
- `platform-versioning` (ClassVersion resolution stages referenced above).
- `platform-workflow` (Workflow-Engine API referenced by Troubleshooting last row).
- `adapter-messaging`, `adapter-http`, `adapter-eventdispatcher` (event-transport recipes).
- `rules-testing` (EventCollector fake for Recipe 4).
