Architecture
Package structure
Section titled “Package structure”packages/events/└── src/ └── users/ └── index.ts # Integration events for the Users domainEach domain has its own subdirectory. The root index.ts is generated automatically — never edit it by hand.
Anatomy of an event
Section titled “Anatomy of an event”Every integration event has three layers that work together:
Zod Schema → Inferred type → Event class(runtime (TypeScript) (@nestjs/cqrs) validation)Real example: UserRegisteredEvent
Section titled “Real example: UserRegisteredEvent”// 1. Reusable base schema (composition)export const UserPayloadSchema = z.object({ email: z.string().email(), id: z.string().uuid(), organizationId: z.string().uuid(),})
// 2. Event schema (extends the base)export const UserRegisteredPayloadSchema = UserPayloadSchema.extend({ registeredAt: z.string().datetime(),})
// 3. Inferred TypeScript type — no duplicationexport type UserRegisteredPayload = z.infer<typeof UserRegisteredPayloadSchema>
// 4. Event class for @nestjs/cqrsexport class UserRegisteredEvent { static readonly EVENT_NAME = 'users.user_registered' as const readonly payload: UserRegisteredPayload
constructor(raw: unknown) { // Validates on construction — fails loudly if the payload is invalid this.payload = UserRegisteredPayloadSchema.parse(raw) }}Why the constructor receives unknown: it forces Zod validation at construction time, both when publishing (emitting module) and when consuming (receiving module from an external broker). The payload never arrives unvalidated.
Conventions
Section titled “Conventions”| Element | Convention | Example |
|---|---|---|
| Directory name | Domain name in plural | users/, admin/, notifications/ |
EVENT_NAME | <domain>.<entity>_<past_verb> | users.user_registered |
| Base schema | <Entity>PayloadSchema | UserPayloadSchema |
| Event schema | <Entity><Action>PayloadSchema | UserRegisteredPayloadSchema |
| TypeScript type | <Entity><Action>Payload | UserRegisteredPayload |
| Event class | <Entity><Action>Event | UserRegisteredEvent |
Available events
Section titled “Available events”| Class | EVENT_NAME | Payload |
|---|---|---|
UserRegisteredEvent | users.user_registered | id, email, organizationId, registeredAt |
UserDeletedEvent | users.user_deleted | userId, deletedAt |
How to use an event
Section titled “How to use an event”Publish (emitter):
import { UserRegisteredEvent } from '@frame/events'
await this.eventBus.publish( new UserRegisteredEvent({ id: user.id, email: user.email, organizationId: user.organizationId, registeredAt: new Date().toISOString(), }))Subscribe (consumer):
import { UserRegisteredEvent } from '@frame/events'
@EventsHandler(UserRegisteredEvent)export class SendWelcomeEmailHandler implements IEventHandler<UserRegisteredEvent> { async handle({ payload }: UserRegisteredEvent) { // payload is typed and already validated by Zod await this.queue.add('send-welcome-email', payload) }}Adding a new event
Section titled “Adding a new event”-
Create or open the
src/<domain>/index.tsfile for the corresponding domain. If the domain does not exist, create the directory. -
Define the Zod schema for the payload. If the event shares fields with others in the same domain, extract a base schema and use
.extend(). -
Infer the TypeScript type with
z.infer<typeof ...>. -
Create the event class with a static
EVENT_NAMEand Zod validation in the constructor. -
Publish and subscribe from the corresponding modules in
apps/nest.