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:
| Attribute | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique within the scope of the producer. Used as idempotency key. |
specversion | "1.0" | Yes | Always "1.0" per CloudEvents v1.0.2. |
type | string | Yes | Reverse-DNS event type with embedded version. See naming. |
source | string | Yes | URI-reference identifying the producer (e.g. "/match-gateway"). |
time | string | Yes | ISO 8601 / RFC 3339 timestamp of when the event occurred. |
data | T | Yes | Domain-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}| Segment | Convention | Example |
|---|---|---|
| prefix | Reverse-DNS, always com.feyenoord | com.feyenoord |
| domain | Lowercase, singular | match |
| event-name | kebab-case, past tense | goal-scored |
| version | v + integer | v1 |
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.
| DO | DON'T |
|---|---|
goal-scored | send-goal-notification |
match-started | notify-match-start |
lineup-announced | process-lineup |
substitution-made | update-substitution |
card-given | handle-card |
Type String Constants
Each event exports its type string as a constant so consumers can opt out of using raw strings:
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:
// 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:
- Create
goal-scored-v2.tswithgoalScoredV2DataSchemaandgoalScoredV2Type - Keep the v1 schema; old publishers/consumers continue to work on the v1 topic
- 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:
// 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 exportAdding a New Event
- Create
{event-name}.tsin the appropriatedomains/{domain}/folder - Define the type constant:
export const {eventName}V1Type = "com.feyenoord.{domain}.{event-name}.v1" - Define the data schema:
export const {eventName}V1DataSchema = ... - Create the envelope schema:
export const {eventName}V1Schema = createCloudEventSchema({eventName}V1DataSchema, {eventName}V1Type) - Export the inferred type:
export type {EventName}V1 = z.infer<typeof {eventName}V1Schema> - Add to barrel exports in
domains/{domain}/index.tsandindex.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:
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
// 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
// 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.