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. Call it only from inside the Thing's own class — unqualified, as PublishEvent(...) / this.PublishEvent(...). 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 function 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(PqEvent.System.Device.Enabled.At(Timestamp(evt), channel).Build());

// ✅ RIGHT — give the Thing a named method; the router calls that
public partial class Channel
{
    public ValueTask<bool> Enabled(DeviceTimestamp ts) =>
        PublishEvent(PqEvent.System.Device.Enabled.At(ts, this).Build()); // this.PublishEvent
}
// router: Route<Channel>(router, EventType.Door, 7, e => e.Parameter3, (c, e) => c.Enabled(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

Reach for this before writing anything by hand. 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.

Two cases produce no generated method, both on purpose: an action of one of the type's functions already publishes that event (call the action instead), or you already declared a method of that name by hand.

Write The Partial Only When The Declaration Cannot Express It

A hand-written method wins over the generated one. Write it when the event needs something the declaration cannot give — an extra parameter, a parameter supplied conditionally, a constant the protocol implies. Keep it inside the Thing's own partial class, with the unqualified PublishEvent (i.e. this.PublishEvent):

public partial class Reader
{
    public ValueTask<bool> Granted(DeviceTimestamp timestamp, Guid personId, Guid credentialId, CancellationToken ct = default) =>
        PublishEvent(
            PqEvent.Access.Granted
                .At(timestamp, this)
                .WithIdentity(personId, credentialId)
                .WithReason("Card scan")
                .Build(),
            ct);
}

A partial that only forwards to the builder and adds nothing — same parameters, no extra values — is work the declaration already does. Declare the event instead and delete the method.

Generated Event Hierarchy

Framework generates nested classes matching taxonomy:

PqEvent.Access.Granted              // pq.event.access.granted
PqEvent.Access.Denied               // pq.event.access.denied
PqEvent.Access.Door.Opened          // pq.event.access.door.opened
PqEvent.Intrusion.Alarm             // pq.event.intrusion.alarm

Builder Methods

All PqEvent builders support:

PqEvent.Access.Granted
    .At(timestamp, thing)             // Required: DeviceTimestamp + Thing
    .WithIdentity(personId, credId)   // Optional for access events
    .WithReason("reason text")        // Optional human-readable reason
    .WithParameter("key", value)      // Optional custom metadata
    .Build()

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)
Event whose parameters the declaration cannot express Hand-written partial on the Thing PqEvent.… inside the Thing's own class
Status snapshot from polling StatusBatch batch.Set(OutputState.Active)
Access grant/deny with identity Function method or declared event await reader.Granted(ts, personId, credentialId)
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 Named method on the Thing await channel.Enabled(ts)never channel.PublishEvent(...) (RULE-032)

See Also