# Events and Jobs Walkthrough for adding cross-feature events and background jobs to a feature. For the architectural rationale, see [ADR-015](../decisions/adr-015-events-and-jobs.md). > **Prerequisite — `@repo/core-events` is optional.** > The event bus (`IEventBus`, `InMemoryEventBus`, `PayloadJobsEventBus`) lives > in `@repo/core-events`, which ships as a scaffoldable package rather than a > permanent fixture. If `packages/core-events/` does not exist in your repo, > run `pnpm turbo gen core-package events` first, then wire the bus into > `apps/web-next/src/server/bind-production.ts` as described in the generator's > next-steps output. > > Background jobs (`IJobQueue`, `gen job`) work without core-events — they only > require `@repo/core-shared/jobs`. Cross-feature event fanout (`gen event consume`) additionally requires core-events. The three rules to keep in mind: - **E0** — Events are for cross-feature decoupling. In-feature reactions are direct use-case calls. - **E1** — Event contracts are public; handlers are private (never re-exported, ESLint-enforced). - **J0** — Jobs are for _deferred_ work (latency, retries, cron). Synchronous code stays synchronous. Three generators do the boilerplate. Each one inserts at fixed `// ` anchor comments that are present in every feature. ```bash pnpm turbo gen event publish # publisher contract pnpm turbo gen event consume # consumer handler + Payload event-task pnpm turbo gen job # background job + Payload TaskConfig ``` --- ## 1. Publish an event Run from the repo root: ```bash pnpm turbo gen event --args publish auth user.signed-up ``` This scaffolds: - `packages/auth/src/events/user-signed-up.event.ts` (the contract — descriptor + Zod schema + type alias) - `packages/auth/src/events/user-signed-up.event.test.ts` - A re-export at the `// ` anchor in `packages/auth/src/index.ts` Then **fill in the schema** with the actual fields the event carries: ```ts // packages/auth/src/events/user-signed-up.event.ts export const userSignedUpEventSchema = z .object({ userId: z.string(), email: z.string().email(), signedUpAt: z.string().datetime(), }) .strict(); ``` Then **publish from a use case**. Add `bus: IEventBus` to the factory signature and call `bus.publish(...)` after the success path: ```ts // packages/auth/src/application/use-cases/sign-up.use-case.ts import type { IEventBus } from "@repo/core-events"; import { userSignedUpEvent } from "../../events/user-signed-up.event"; export const signUpUseCase = ( usersRepository: IUsersRepository, authenticationService: IAuthenticationService, bus: IEventBus, ) => async (input: SignUpInput): Promise => { // ... existing logic ... await bus.publish(userSignedUpEvent, { userId: newUser.id, email: `${newUser.username}@example.local`, signedUpAt: new Date().toISOString(), }); return signUpOutputSchema.parse({ session, cookie }); }; ``` Then **update DI**. Both `bind-production.ts` and `bind-dev-seed.ts` already receive `bus` as a parameter; just thread it into the factory call: ```ts const wrappedSignUp = withSpan( tracer, { name: "auth.signUp", op: "use-case" }, withCapture( logger, { feature: "auth", layer: "use-case", name: "auth.signUp" }, signUpUseCase(repo, authService, bus), ), ); ``` If the feature has a default-fallback `module.ts` that resolves use cases via `.toDynamicValue()`, give it a fresh `new InMemoryEventBus()` per resolution — the module is the test-mock fallback path, not a runtime path. **Verify:** ```bash pnpm --filter @repo/auth lint typecheck test ``` The publishing test asserts on the `RecordingEventBus`'s `published` array. See `signUpUseCase`'s test for the pattern. --- ## 2. Consume an event Run from the consumer's perspective (here `marketing-pages` consumes `auth`): ```bash pnpm turbo gen event --args consume marketing-pages user.signed-up auth ``` This scaffolds: - `packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts` + test - `packages/marketing-pages/src/integrations/cms/jobs/__events-auth-user-signed-up.task.ts` (the Payload event-task that closes the production-bus loop) …and modifies four files at their anchors: - `src/di/symbols.ts` — adds the handler symbol at `// ` - `src/di/bind-production.ts` — wraps the handler in span+capture, binds to the symbol, and calls `bus.subscribe(...)` at `// ` - `src/di/bind-dev-seed.ts` — same as production, identical block - `src/integrations/cms/index.ts` — re-exports the event-task at `// ` so `core-cms` aggregates it The generator prints two manual edits: **1. Add the imports** at the top of both bind files (the modify-block can't add imports): ```ts import { userSignedUpEvent } from "@repo/auth"; import { onAuthUserSignedUpHandler } from "../events/handlers/on-auth-user-signed-up.handler"; ``` **2. Add the cross-feature dep** to the consumer's `package.json`: ```json "@repo/auth": "workspace:*" ``` Then **implement the handler body**. The factory shape is `(deps) => async (event) => Promise`. Inject what the handler needs — typically a job queue if the reaction is deferred: ```ts // packages/marketing-pages/src/events/handlers/on-auth-user-signed-up.handler.ts import type { UserSignedUpEvent } from "@repo/auth"; import type { IJobQueue } from "@repo/core-shared/jobs"; export const onAuthUserSignedUpHandler = (queue: IJobQueue) => async (event: UserSignedUpEvent): Promise => { await queue.enqueue("marketing-pages.send-welcome-email", { userId: event.userId, email: event.email, }); }; ``` The generator emitted `onAuthUserSignedUpHandler()` with no args in the bind block. Edit it to pass `queue`: ```ts onAuthUserSignedUpHandler(queue), ``` **Verify:** ```bash pnpm --filter @repo/marketing-pages lint typecheck test pnpm --filter @repo/core-cms typecheck ``` Handlers must NOT be re-exported from the consumer's public surface (rule E1 — enforced by `core-eslint`'s `no-handler-reexport` rule). --- ## 3. Add a job ```bash pnpm turbo gen job --args marketing-pages send-welcome-email typed ``` The third arg picks the input shape: `void` for parameter-less jobs, `typed` for jobs that take a payload. This scaffolds: - `packages/marketing-pages/src/jobs/send-welcome-email.job.ts` + test - `packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts` …and modifies four files at the job anchors (``, both ``, ``). **Fill in the schema and body.** Inject the dependencies you need: ```ts // packages/marketing-pages/src/jobs/send-welcome-email.job.ts import { z } from "zod"; import type { IMailerService } from "../application/services/mailer.service.interface"; export const sendWelcomeEmailInputSchema = z .object({ userId: z.string(), email: z.string().email(), }) .strict(); export type SendWelcomeEmailInput = z.infer; export type ISendWelcomeEmailJob = ReturnType; export const sendWelcomeEmailJob = (mailer: IMailerService) => async (input: SendWelcomeEmailInput): Promise => { sendWelcomeEmailInputSchema.parse(input); await mailer.sendWelcome(input.userId, input.email); }; ``` **Wire the dependency in both binders.** The generator emitted `sendWelcomeEmailJob()`; edit to pass the mailer: ```ts sendWelcomeEmailJob(mailer), ``` **For dev-seed, register the slug** with the `InMemoryJobQueue` so `enqueue` actually fires: ```ts if ( "register" in queue && typeof (queue as { register?: unknown }).register === "function" ) { ( queue as { register: (slug: string, h: (input: unknown) => Promise) => void; } ).register("marketing-pages.send-welcome-email", async (input) => { const wrapped = marketingPagesContainer.get( MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob, ); await wrapped(input as SendWelcomeEmailInput); }); } ``` Production skips this — the generated Payload task in `integrations/cms/jobs/.task.ts` resolves the wrapped job from the per-feature container at runtime. **Edit Payload's `inputSchema`** in the generated `.task.ts` to match your Zod schema (Payload's `inputSchema` is field-config, not a TypeScript shape): ```ts inputSchema: [ { name: "userId", type: "text", required: true }, { name: "email", type: "email", required: true }, ], ``` **Verify:** ```bash pnpm --filter @repo/marketing-pages lint typecheck test ``` --- ## Cron schedules Job cron schedules don't live in the feature's job file or generator output — they live in `core-cms`'s `buildConfig({ jobs: { ... } })`. If a job runs periodically, register it in the Payload jobs config alongside the task slug. ## Anchor protocol Six fixed anchor comments live in every feature: | File | Anchor | Used by | | ------------------------------- | -------------------------------- | ------------------------------ | | `src/index.ts` | `// ` | `gen event publish` | | `src/di/symbols.ts` | `// ` | `gen event consume` | | `src/di/symbols.ts` | `// ` | `gen job` | | `src/di/bind-production.ts` | `// ` | `gen event consume` | | `src/di/bind-production.ts` | `// ` | `gen job` | | `src/di/bind-dev-seed.ts` | `// ` | `gen event consume` | | `src/di/bind-dev-seed.ts` | `// ` | `gen job` | | `src/integrations/cms/index.ts` | `// ` | `gen event consume`, `gen job` | A CI guard at `packages/core-eslint/anchors.test.js` asserts the anchors stay present in every feature. Remove an anchor and CI fails — restore it and the test goes green. ## End-to-end test reference `apps/web-next/src/__tests__/sign-up-welcome-email.test.ts` exercises the full chain in dev-seed mode: `bindAllDevSeed()` → `signUpController(...)` → `bus.publish` → consumer handler → `queue.enqueue` → InMemoryJobQueue dispatch → `mailer.sendWelcome` recorded. Use it as a template when adding cross-feature flows.