Skip to content

Pattern 9: Event Publishing Best Practices

Prefer Function Methods

Use generated function methods whenever available. They publish the mapped event and update function status in one call.

var timestamp = evt.Timestamp;

await door.Opened(timestamp);
await door.LockedRemote(timestamp);
await zone.Alarm(timestamp);
await output.Activated(timestamp);

Call methods directly on the Thing. Use function properties mainly for StatusBatch polling updates.

PublishEvent Belongs to the Thing — Never Call It From Outside

PublishEvent is the Thing's own concern. Never call it on a Thing reference you received from outside (channel.PublishEvent(...), door.PublishEvent(...)) in an event router, command handler, or status-polling class.

It is public only because generated methods must reach it across the partial-class boundary — that visibility is a technical necessity, not an invitation. Publishing from outside the Thing bypasses the Thing's ability to update its own state in the same step, and scatters a Thing's events across the codebase instead of keeping them on one surface. This is RULE-032.

// ❌ WRONG — router publishes on a Thing it was handed
private static ValueTask<bool> ChannelEnabled(Channel channel, HistoryEvent evt) =>
    channel.PublishEvent(...);

// ✅ RIGHT — declare pq.event.system.device.enabled on Channel; the router calls the generated method
Route<Channel>(router, EventType.Door, 7, e => e.Parameter3, (c, e) => c.DeviceEnabled(Timestamp(e)));

The build reports an outside call as PQC232 — see the diagnostic page for the full explanation.

Declare The Event And The Method Appears

This is how an event no function action covers gets published. List the event under the device type in adapter-registration.yaml and the generator puts a named publication method on the Thing:

device_types:
  - type_id: "AcmePanel"
    events:
      - pq.event.system.configuration.changed
// router calls the generated method — no PublishEvent anywhere in your code
await panel.ConfigurationChanged(timestamp);

The name is the fewest trailing segments of the event id that stay unique on that type, so pq.event.access.door.forced becomes DoorForced. The event's taxonomy parameters become method parameters: required as plain arguments, optional as nullable with a default. Pass the values the device gave you — a dropped parameter is a thinner audit record.

See Declared Event Methods for names, parameters and inheritance.

An action of one of the type's functions that already publishes the event produces no generated method, on purpose: call the action instead.

Never Build The Event By Hand

Adapter code never references PqEvent and never calls EventBuilder.Event(...) — not in a router, not in a partial of the Thing. The build reports both as PQC203. Every call shape fits the generated method:

  • a value the device reports only sometimes → pass null when it is missing
  • a value the protocol implies → pass a constant at the call site
// generated on Panel — do not write this yourself
public ValueTask<bool> HardwareFault(DeviceTimestamp timestamp, string faultDescription, string? faultCode = null)

// router
await panel.HardwareFault(timestamp, faultDescription: "Device licence removed");

When To Use Which

Scenario Use Example
Standard function state/event Function method await door.Opened(ts)
Taxonomy event with no function behind it Declare it in events: await panel.ConfigurationChanged(ts)
Status snapshot from polling StatusBatch batch.Set(OutputState.Active)
Access grant/deny with identity Function method await reader.AccessGranted(ts, credentialId: credentialId, personId: personId)
Unmapped vendor event Extension method thing.Unknown(ts, eventType, reason)
Unresolved Thing Extension method thing.Unknown(ts, eventType, reason) with unresolved diagnostics
Publish from a router/command/poll Generated method on the Thing await channel.DeviceEnabled(ts) — never channel.PublishEvent(...) (RULE-032)

See Also