Skip to content

Event

A past-tense fact. Something the system observed and committed.

Events are immutable. They flow into actors (state transitions), into projections (read models), and into listeners (reactions). They are the spine of an Nwire app.

Shape

ts
import { defineEvent } from "@nwire/messages";
import { z } from "zod";

export const AnswerWasSubmitted = defineEvent({
  name: "submissions.answer-was-submitted",
  description: "Avi just tapped Submit.",
  outcome: "milestone",            // 'success' | 'failure' | 'warning' | 'milestone'
  businessWeight: 10,              // for dashboard weighting
  audience: ["product", "ops"],    // who cares — Studio filter
  schema: z.object({
    submissionId: z.string(),
    studentId: z.string(),
    exerciseId: z.string(),
    submittedAt: z.string().datetime(),
  }),
});

The definition is a callable factory — call it in a handler to mint a validated event message:

ts
return AnswerWasSubmitted({ submissionId, studentId, exerciseId, submittedAt: now });

Naming — <Subject>Was<VerbPast>

Events are past-tense facts. The exported identifier should make that impossible to misread:

✅  AnswerWasSubmitted        OrderWasPlaced          EmailWasVerified
❌  SubmitAnswer              PlaceOrder              VerifyEmail        (imperative — read as commands)
❌  AnswerSubmittedEvent      OrderPlacedEvent                            (the noise suffix tells you nothing)

Was reads aloud as a fact in any sentence: "AnswerWasSubmitted — when this happens, …". The language fights you if you accidentally write present tense, which is the point. See the conservation-of-meaning principle.

Local by default; .public() to cross

An event is local by default — listeners, actors, and projections in its own app react to it, and it never leaves the boundary. To make an event part of the app's contract with other bounded contexts, mark it:

ts
import { defineEvent } from "@nwire/messages";
import { z } from "zod";

export const OrderWasPlaced = defineEvent({
  name: "orders.order-was-placed",
  schema: z.object({ orderId: z.string() }),
}).public();

.public() returns a marked clone (the original definition stays unmarked), and it gates exactly one thing: the boundary. An action's ctx.publish refuses an event without the marker, so an internal fact can never leak across it. See events and listeners for the emit / publish split and outbound sinks for how a published event leaves the process.

Outcome

Lets Studio aggregate success/failure rates without parsing event names:

  • 'success' (default) — positive thing happened
  • 'failure' — domain-meaningful failure (not a system error)
  • 'milestone' — significant progress
  • 'warning' — anomaly worth surfacing

Flow

Handler  ─returns event / ctx.publish─▶  shared publish path

                                           ├─▶ LocalDelivery chain:
                                           │     idempotency → actors → projections → workflows
                                           ├─▶ `when` listeners (local fanout)
                                           ├─▶ outbound sink / bus  — only if `.public()`
                                           └─▶ telemetry: event.published

Handler  ─ctx.emit─▶  same local fold + listeners, never the outbound leg
                                           └─▶ telemetry: event.emitted

Idempotency

Events DON'T have built-in idempotency. The actor's state machine handles "have I already seen this?" — typically by checking the actor's current state. For inbound events from external systems use defineInbox to dedup by message id.

See also

MIT licensed.