Skip to content

Events

Events are the primary mechanism for asynchronous communication between services. All events follow the CloudEvents v1.0.2 specification with Zod schemas for type-safe validation.

Event contracts live in @repo/contracts/events so every service (publisher and consumer) shares the same schema definitions, type strings, and TypeScript types.

CloudEvent Envelope

Every event is wrapped in a CloudEvent envelope with the following attributes:

AttributeTypeRequiredDescription
idstringYesUnique within the scope of the producer. Used as idempotency key.
specversion"1.0"YesAlways "1.0" per CloudEvents v1.0.2.
typestringYesReverse-DNS event type with embedded version. See naming.
sourcestringYesURI-reference identifying the producer (e.g. "/match-gateway").
timestringYesISO 8601 / RFC 3339 timestamp of when the event occurred.
dataTYesDomain-specific payload. Typed per event via Zod schema.

Dapr envelope

When events are delivered via Dapr pub/sub, Dapr wraps the payload in its own CloudEvent envelope and overwrites the top-level type field with "com.dapr.event.sent". The original event type is preserved inside data. Consumers should validate data directly against the domain-specific data schema, not the outer envelope. The envelope can be validated first to ensure that the cloud event is valid.

Naming

Type String Format

com.feyenoord.{domain}.{event-name}.v{n}
SegmentConventionExample
prefixReverse-DNS, always com.feyenoordcom.feyenoord
domainLowercase, singularmatch
event-namekebab-case, past tensegoal-scored
versionv + integerv1

Full example: com.feyenoord.match.goal-scored.v1

Event Naming Rules

Events describe what happened in the domain, using past tense. The publisher has no knowledge of consumers.

DODON'T
goal-scoredsend-goal-notification
match-startednotify-match-start
lineup-announcedprocess-lineup
substitution-madeupdate-substitution
card-givenhandle-card

Type String Constants

Each event exports its type string as a constant so consumers can opt out of using raw strings:

typescript
export const goalScoredV1Type = "com.feyenoord.match.goal-scored.v1";

Dapr Topics

The CloudEvent type string is used directly as the Dapr pub/sub topic name. This means:

  • The publisher calls pubsub.publish("pubsub", event.type, event); no separate topic mapping needed
  • Each event type is a distinct, independently subscribable topic
  • Consumers subscribe to exactly the events they care about

Examples: com.feyenoord.match.goal-scored.v1, com.feyenoord.match.lineup-announced.v1

Versioning

Version is embedded in the type string. Each version is a distinct, independently routable event type and a distinct Dapr topic.

Backwards-Compatible Changes

Adding optional fields to the data payload. Old consumers ignore unknown fields:

typescript
// v1 data schema; existing consumers still work
const goalScoredV1DataSchema = matchEventBaseSchema.and(
	z.object({
		playerId: z.string(),
		playerName: z.string(),
		goalType: goalTypeSchema,
	}),
);

Breaking Changes

Removing, renaming, or changing the type of existing fields requires a new version:

  1. Create goal-scored-v2.ts with goalScoredV2DataSchema and goalScoredV2Type
  2. Keep the v1 schema; old publishers/consumers continue to work on the v1 topic
  3. Both versions coexist as separate topics; consumers migrate at their own pace by subscribing to v2 and unsubscribing from v1

Consumer Strategy

Since each subscription is topic-per-event-type, the topic itself identifies the event. Consumers validate data directly against the specific data schema for that topic:

typescript
// POST /events/match/goal-scored (Dapr calls this when a message arrives on the topic)
const parsed = goalScoredV1DataSchema.safeParse(body.data);

if (!parsed.success) {
	logger.warn("Invalid GoalScoredV1 payload", { error: parsed.error });
}

// parsed.data is fully typed as GoalScoredV1Data
await handleGoalScored(parsed.data);

Unknown or malformed payloads are dropped with a warning, not retried. This is expected during rollouts when a new event version is published before all consumers have updated.

File Structure

packages/contracts/src/events/
├── cloud-event.ts              ← envelope schema, factory, types
├── index.ts                    ← barrel export
└── domains/
    └── match/
        └── index.ts            ← barrel export

Adding a New Event

  1. Create {event-name}.ts in the appropriate domains/{domain}/ folder
  2. Define the type constant: export const {eventName}V1Type = "com.feyenoord.{domain}.{event-name}.v1"
  3. Define the data schema: export const {eventName}V1DataSchema = ...
  4. Create the envelope schema: export const {eventName}V1Schema = createCloudEventSchema({eventName}V1DataSchema, {eventName}V1Type)
  5. Export the inferred type: export type {EventName}V1 = z.infer<typeof {eventName}V1Schema>
  6. Add to barrel exports in domains/{domain}/index.ts and index.ts

Publisher Example

A publisher creates a CloudEvent and publishes it via the Dapr pub/sub API. The type string is used directly as the topic name:

typescript
import { goalScoredV1Type, GoalScoredV1Data } from "@repo/contracts/events";

const event: GoalScoredV1Data = {
	matchId: "1",
	playerName: "Ayase Ueda",
	goalType: "regular",
};

// Topic = event type string. Publisher has no knowledge of consumers.
await daprClient.pubsub.publish("pubsub", event.type, event);

Consumer Example

Consumers register subscriptions programmatically. The app exposes a GET /dapr/subscribe endpoint that returns the list of topics and their handler routes. Dapr calls the handler route with a POST request when a message arrives.

Subscription Registration

typescript
// GET /dapr/subscribe
// Each domain module declares its own subscriptions as data.
// The composition root collects and returns them all.
[
	{
		pubsubname: "pubsub",
		topic: "com.feyenoord.match.goal-scored.v1",
		route: "/events/match/goal-scored",
	},
	{
		pubsubname: "pubsub",
		topic: "com.feyenoord.match.lineup-announced.v1",
		route: "/events/match/lineup-announced",
	},
];

Handler

typescript
// POST /events/match/goal-scored
// body is the Dapr CloudEvent envelope; the original payload is in body.data
async function handleGoalScoredEvent(body: unknown) {
	const parsed = goalScoredV1DataSchema.safeParse(body.data);

	if (!parsed.success) {
		logger.warn("Invalid GoalScoredV1 payload", { error: parsed.error });
	}

	await processGoalScored(parsed.data);
}

Endpoints should return a 200 status code to signal that the event was succesfully processed. If any other code is returned, Dapr will attempt to redeliver the message in accordance with at-least-once delivery semantics.