Also folds in the spec correction noted in the plan's known follow-up \#1: subscribe takes consumerFeature: string as a second arg between descriptor and handler. § 3.3 and § 5.4 updated.
34 KiB
Cross-feature events and background jobs — Design
Date: 2026-05-08
Status: Draft (pending user review)
Supersedes: the deferred placeholders at docs/architecture/vertical-feature-spec.md:260 ("No effects/, jobs/, events/ unless the feature grows them") and :662 ("no core-events package yet; spec addendum v4's optional core-events stays deferred").
Companion ADR: ADR-015 (to be created during implementation; this spec is the long-form design that ADR-015 distills).
1. Context and motivation
This template currently has no convention for two real architectural concerns:
- Cross-feature reactions. Boundary rules forbid
feature → featureimports (R6 inAGENTS.md). Whenauthshould trigger work inmarketing-pages(e.g. send a welcome email after sign-up), there is no documented seam. Today the only options are smell (illegal imports) or absence (the reaction can't be expressed cleanly). - Deferred / scheduled / retryable work. Use cases run synchronously on the request path. There is no documented place for work that needs durability, retries, scheduling, or rate-shaping. Payload 3.x ships a built-in jobs queue (
payload.jobs), but the template has no convention for where job code lives in a feature, how it is wired through DI and the span+capture sandwich, or how it is triggered.
This spec defines both, in one document, because they share machinery (factory + sandwich, dev/prod parity via bindAll(), recording-test rules) and a conceptual model (typed dispatch through a core abstraction). The two concerns are kept conceptually distinct via three load-bearing rules below.
2. Conceptual model and rules
2.1 What events and jobs are for
| Event | Job | |
|---|---|---|
| Purpose | A feature publishes a domain fact (e.g. auth.user.signed-up). Other features may react. |
Deferred / scheduled / retryable work, owned and triggered by one feature. |
| Caller | Always the publishing feature's own use case, after it succeeds. | Any use case, controller, cron schedule, admin UI, or another job. |
| Audience | 0..N other features, unknown to the publisher. | One feature (itself). |
| Transport | IEventBus (in-memory in dev/test, Payload-jobs fan-out in prod). |
IJobQueue (in-memory in dev/test, Payload jobs in prod). |
| Why it exists | Cross-feature decoupling — boundary rules forbid feature → feature imports, so we need a seam. | Durability, retries, scheduling, rate-shaping, getting work off the request path. |
2.2 The three rules
These are the load-bearing discipline; everything mechanical follows from them.
- Rule E0 — Events are for cross-feature decoupling. If the reaction lives in the same feature, do not use the bus. Just call another use case. The bus is a seam, not a router.
- Rule E1 — Event contracts are exported; handlers are private. A feature's
events/<name>.event.tsis re-exported from the package root (already where contracts live per R18–R21). A feature'sevents/handlers/*.handler.tsis wired only inside that feature's ownbind-production/bind-dev-seedand is never re-exported from any subpath. - Rule J0 — Jobs are for deferred work, not abstraction. If you can do the work synchronously in the request path and it doesn't need retries, durability, or scheduling, do not make it a job. A job has operational cost (admin UI noise, retry semantics, eventual consistency) that synchronous code does not.
These rules go verbatim into ADR-015 and into AGENTS.md § Per-Package Conventions.
2.3 The seam (cross-feature event flow)
publisher (auth) consumer (marketing-pages)
───────────────── ──────────────────────────
events/user-signed-up.event.ts events/handlers/on-auth-user-signed-up.handler.ts
└── re-exported from @repo/auth root ←── imports contract from @repo/auth (root, allowed)
publishes via subscribes via
IEventBus (core-events) IEventBus (core-events)
↘ ↙
@repo/core-events bus
The publisher never knows the consumer exists. The consumer imports only the publisher's contract (event name + payload schema), which already lives behind the root export. The existing ESLint boundary rules permit this — no new boundary rule is required for the cross-feature event flow.
3. New and extended packages
3.1 @repo/core-events (new)
A new package, tagged core in the boundary rules, owns the bus abstraction and its two implementations.
packages/core-events/
src/
event-descriptor.ts # defineEvent(name, schema), EventDescriptor<TName, TSchema>
event-bus.interface.ts # IEventBus
in-memory-event-bus.ts # InMemoryEventBus (dev/test)
in-memory-event-bus.test.ts
payload-jobs-event-bus.ts # PayloadJobsEventBus (prod)
payload-jobs-event-bus.test.ts
symbols.ts # CORE_EVENTS_SYMBOLS.IEventBus
index.ts # public surface
package.json
tsconfig.json
vitest.config.ts
eslint.config.js
AGENTS.md
core-events is intentionally a separate package, not folded into core-shared, because:
core-sharedowns primitives (Zod, env, instrumentation, tRPC init).core-eventsowns infrastructure.- The Payload-jobs-backed bus implementation depends on
payload.core-shareddoes not. - A future bus replacement (Kafka, NATS, etc.) is one-package swap — easy to spot and easy to test.
Boundary tag: core. Allowed dependents: feature, core, core-composition, app (everything that may already depend on core). Allowed dependencies: core-shared, tooling.
3.2 @repo/core-shared/jobs/ (extension)
core-shared gains a jobs/ subdirectory with the queue abstraction:
packages/core-shared/src/jobs/
job-queue.interface.ts # IJobQueue
in-memory-job-queue.ts # InMemoryJobQueue
payload-job-queue.ts # PayloadJobQueue
symbols.ts # CORE_SHARED_JOBS_SYMBOLS.IJobQueue
index.ts # exported via @repo/core-shared/jobs subpath
A new public subpath export @repo/core-shared/jobs is added (./jobs in package.json exports map). This keeps the queue addressable from features without dragging Payload into core-shared's root import surface.
Boundary effect: none. core-shared was already core-tagged; the new subpath is a cosmetic extension of the existing public API.
3.3 Interfaces — exact shapes
// packages/core-events/src/event-descriptor.ts
import type { z } from "zod";
export type EventDescriptor<TName extends string, TSchema extends z.ZodType> = {
readonly name: TName;
readonly schema: TSchema;
};
export function defineEvent<TName extends string, TSchema extends z.ZodType>(
name: TName,
schema: TSchema,
): EventDescriptor<TName, TSchema> {
return { name, schema };
}
// packages/core-events/src/event-bus.interface.ts
import type { z } from "zod";
import type { EventDescriptor } from "./event-descriptor";
export type EventHandler<T> = (event: T) => Promise<void>;
export interface IEventBus {
publish<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
payload: T,
): Promise<void>;
subscribe<T>(
descriptor: EventDescriptor<string, z.ZodType<T>>,
consumerFeature: string,
handler: EventHandler<T>,
): void;
}
// packages/core-shared/src/jobs/job-queue.interface.ts
export interface IJobQueue {
enqueue<T>(
taskSlug: string,
input: T,
options?: { runAt?: Date },
): Promise<{ jobId: string }>;
}
The bus is generic over the schema's inferred type, so bus.publish(userSignedUpEvent, payload) infers payload's type from userSignedUpEvent.schema and rejects mismatches at compile time.
The queue is intentionally minimal — a single enqueue method with optional runAt. Cron schedules live in the Payload jobs config (in core-cms's buildConfig), not in this interface.
4. Per-feature folder layout
A feature that uses events and/or jobs has these additional directories (all optional; absent if unused):
packages/<feature>/src/
events/
<event-name>.event.ts # publisher: contract (Zod + defineEvent)
<event-name>.event.test.ts
handlers/
on-<publisher>-<event>.handler.ts # consumer: factory
on-<publisher>-<event>.handler.test.ts
jobs/
<job-name>.job.ts # factory + Zod schema
<job-name>.job.test.ts
integrations/cms/jobs/
<job-name>.task.ts # Payload TaskConfig glue
index.ts # barrel for core-cms aggregation
Naming rules — keep file names and wire names consistent but distinct:
- Event contract file name:
<event-kebab>.event.ts, all-kebab (e.g.user-signed-up.event.ts). - Event name on the wire:
<feature>.<event-dotted>where<event-dotted>is past-tense, dot-separated (e.g.auth.user.signed-up). The fileuser-signed-up.event.tsdefines the event namedauth.user.signed-up. - Event handler file name:
on-<publisher>-<event-kebab>.handler.tswhere<event-kebab>matches the publisher's contract file (e.g.on-auth-user-signed-up.handler.tsconsumesauth.user.signed-up). Publisher prefix is mandatory to keep handler names unique when one consumer subscribes to events from multiple publishers. - Job file name:
<job-kebab>.job.ts, verb-noun kebab (e.g.send-welcome-email.job.ts). - Job slug on the wire:
<feature>.<job-kebab>(e.g.marketing-pages.send-welcome-email).
5. File shapes
5.1 Event contract (publisher)
// packages/auth/src/events/user-signed-up.event.ts
import { z } from "zod";
import { defineEvent } from "@repo/core-events";
export const userSignedUpEventSchema = z
.object({
userId: z.string(),
email: z.string().email(),
signedUpAt: z.string().datetime(),
})
.strict();
export type UserSignedUpEvent = z.infer<typeof userSignedUpEventSchema>;
export const userSignedUpEvent = defineEvent(
"auth.user.signed-up",
userSignedUpEventSchema,
);
Re-exported from packages/auth/src/index.ts:
// <gen:events>
export {
userSignedUpEvent,
userSignedUpEventSchema,
type UserSignedUpEvent,
} from "./events/user-signed-up.event";
The // <gen:events> anchor comment is part of the generator protocol (§ 9).
5.2 Publisher use case
// 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 =
(users: IUsersRepository, bus: IEventBus) =>
async (input: SignUpInput): Promise<SignUpOutput> => {
const user = await users.create(input);
await bus.publish(userSignedUpEvent, {
userId: user.id,
email: user.email,
signedUpAt: new Date().toISOString(),
});
return signUpOutputSchema.parse(user);
};
The use-case factory gains a bus: IEventBus constructor parameter. The publisher always awaits publish. What the bus does with the await is the bus's call (in-memory: synchronous fan-out; Payload-jobs: returns after enqueue, not after delivery). The publisher's contract is "I told the bus."
DI binding for this use case is updated to inject the bus:
bind<ISignUpUseCase>(AUTH_SYMBOLS.ISignUpUseCase)
.toDynamicValue((ctx) =>
withSpan(
tracer,
{ name: "auth.signUp", op: "use-case" },
withCapture(
logger,
{ feature: "auth", layer: "use-case", name: "auth.signUp" },
signUpUseCase(
ctx.container.get(AUTH_SYMBOLS.IUsersRepository),
ctx.container.get(CORE_EVENTS_SYMBOLS.IEventBus),
),
),
),
);
5.3 Event handler (consumer)
// 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 type IOnAuthUserSignedUpHandler = ReturnType<typeof onAuthUserSignedUpHandler>;
export const onAuthUserSignedUpHandler =
(queue: IJobQueue) =>
async (event: UserSignedUpEvent): Promise<void> => {
await queue.enqueue("marketing-pages.send-welcome-email", {
userId: event.userId,
email: event.email,
});
};
The handler imports UserSignedUpEvent from @repo/auth (root, allowed). It is a factory of shape (deps) => async (event: T) => Promise<void> — same shape as a use case, but with void return and no presenter.
5.4 Subscription (consumer's bind-*)
// packages/marketing-pages/src/di/bind-production.ts (excerpt)
export function bindProductionMarketingPages(config: SanitizedConfig): void {
const container = getMarketingPagesContainer(config);
// ... repository bindings (existing) ...
const tracer = container.get<ITracer>(INSTRUMENTATION_SYMBOLS.TRACER);
const logger = container.get<ILogger>(INSTRUMENTATION_SYMBOLS.LOGGER);
const bus = container.get<IEventBus>(CORE_EVENTS_SYMBOLS.IEventBus);
const queue = container.get<IJobQueue>(CORE_SHARED_JOBS_SYMBOLS.IJobQueue);
// <gen:event-handlers>
const onAuthUserSignedUp = withSpan(
tracer,
{ name: "marketing-pages.onAuthUserSignedUp", op: "event-handler" },
withCapture(
logger,
{
feature: "marketing-pages",
layer: "event-handler",
name: "marketing-pages.onAuthUserSignedUp",
},
onAuthUserSignedUpHandler(queue),
),
);
bus.subscribe(userSignedUpEvent, "marketing-pages", onAuthUserSignedUp);
}
The handler is wrapped in the same span+capture sandwich as use cases (R41–R44 from docs/decisions/adr-014-instrumentation-sentry.md), with two new tag values: op: "event-handler", layer: "event-handler". The // <gen:event-handlers> anchor marks where gen event consume inserts new blocks.
Note on bind-* side effects. Today the bindProduction<Feature> / bindDevSeed<Feature> functions are pure container-modification: they only call container.bind(...). With this spec, they additionally call bus.subscribe(...) for each event handler the feature consumes. This is still boot-time-only wiring (the binders run once during bindAll()), but it is a small departure worth flagging. The binders remain idempotent: calling bindAll() twice is incorrect today and remains incorrect after this change.
5.5 Job
// packages/marketing-pages/src/jobs/send-welcome-email.job.ts
// Illustrative — assumes a future mailer service exists in marketing-pages.
// The shipped end-to-end test in § 13 substitutes a recording mailer.
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<typeof sendWelcomeEmailInputSchema>;
export type ISendWelcomeEmailJob = ReturnType<typeof sendWelcomeEmailJob>;
export const sendWelcomeEmailJob =
(mailer: IMailerService) =>
async (input: SendWelcomeEmailInput): Promise<void> => {
sendWelcomeEmailInputSchema.parse(input); // queue payload arrives as unknown
await mailer.sendWelcome(input.userId, input.email);
};
Differences from a use case:
- Boundary parse at the top of the body. The queue payload arrives as
unknown(from a serialized JSON column), so the job parses it itself — same rolesafeParseplays in a controller. - No output schema and no presenter. Jobs are fire-and-forget; what they return goes to the queue's bookkeeping, not to a caller.
- Same factory + sandwich wiring at DI bind time.
5.6 Payload task glue
// packages/marketing-pages/src/integrations/cms/jobs/send-welcome-email.task.ts
import type { TaskConfig } from "payload";
import { getMarketingPagesContainer } from "../../../di/container";
import { MARKETING_PAGES_SYMBOLS } from "../../../di/symbols";
import type { ISendWelcomeEmailJob } from "../../../jobs/send-welcome-email.job";
export const sendWelcomeEmailTask: TaskConfig<"marketing-pages.send-welcome-email"> = {
slug: "marketing-pages.send-welcome-email",
inputSchema: [
/* Payload field config matching the Zod schema */
],
retries: { attempts: 3, backoff: { type: "exponential", delay: 1000 } },
handler: async ({ req, input }) => {
const handler = getMarketingPagesContainer(req.payload.config).get<ISendWelcomeEmailJob>(
MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob,
);
await handler(input);
return { output: {} };
},
};
Re-exported from packages/marketing-pages/src/integrations/cms/index.ts alongside collections, behind the // <gen:job-tasks> anchor. core-cms then aggregates them:
// packages/core-cms/src/index.ts (excerpt)
import {
pagesCollection,
siteSettingsGlobal,
sendWelcomeEmailTask,
} from "@repo/marketing-pages/cms";
export default buildConfig({
collections: [pagesCollection /* , ... */],
globals: [siteSettingsGlobal /* , ... */],
jobs: { tasks: [sendWelcomeEmailTask /* , ... */] },
});
No new subpath, no new boundary rule. ./cms does the same job for tasks that it already does for collections.
6. Bus and queue implementations
6.1 Two bus implementations, swapped by bindAll()
| Implementation | Behavior | Default for |
|---|---|---|
InMemoryEventBus |
Validates payload via descriptor.schema.parse, then await Promise.allSettled(handlers). Publisher's publish resolves after all handlers settle. Per-handler errors are captured by each handler's own sandwich (withCapture) and swallowed by the bus (publisher never sees them). One config knob: failFast: boolean (default false) for tests that want to assert handler failures bubble. |
dev, tests, pnpm dev |
PayloadJobsEventBus |
Validates payload, then enqueues one Payload task per subscriber (__events.<event-name>.<consumer-feature>). publish returns after enqueue, not after delivery. Retries + durability come from Payload. |
production |
Decision: swallow vs failFast in dev/test. Swallow by default because:
- Mirrors production semantics —
PayloadJobsEventBusis async; a publisher in prod never sees a downstream handler error, so dev should match. - Per-handler sandwiches already capture and report errors via
logger.captureException. The publisher does not need to re-handle them. - Tests that want to assert handler failure pass
failFast: trueto the bus constructor — explicit opt-in.
6.2 Two queue implementations, swapped by bindAll()
| Implementation | Behavior | Default for |
|---|---|---|
InMemoryJobQueue |
Resolves the task's handler from the local container and runs it on setImmediate. Honors runAt via setTimeout. Returns a synthetic jobId. |
dev, tests |
PayloadJobQueue |
Wraps payload.jobs.queue({ task, input, waitUntil: options?.runAt }). Returns the Payload job id. |
production |
6.3 Selection rule (extends existing bindAll())
The existing app-level bindAll() dispatcher (in apps/web-next/src/server/bind-production.ts) gains a new bus/queue swap step that runs before any per-feature binder (so per-feature binders can container.get<IEventBus>(...) without ambiguity). It is independent of, and runs alongside, the existing Rule 0 (DSN → Sentry vs Noop instrumentation). Both the bus and the queue follow the same three-mode rule the repositories already use:
USE_DEV_SEED === "true"→InMemoryEventBus+InMemoryJobQueue(anyNODE_ENV)NODE_ENV === "production"→PayloadJobsEventBus+PayloadJobQueue- otherwise →
InMemoryEventBus+InMemoryJobQueue(developer default)
This means pnpm dev boots without Payload and events/jobs still work end-to-end, and USE_DEV_SEED=true pnpm start (or any prod-build smoke test) works without a Payload connection. Same trick that makes the existing repository binding work today.
6.4 Why IJobQueue lives in core-shared and IEventBus lives in core-events
IJobQueueworks standalone. A feature can have jobs without ever publishing or subscribing to events. Putting the queue incore-sharedkeeps simple cases simple — features only depend oncore-eventsif they actually use the bus.- The bus depends on the queue (in production).
PayloadJobsEventBusenqueues subscriber tasks viaIJobQueue.core-eventstherefore depends oncore-shared— the existing direction (coremay depend oncore). - A future bus replacement does not affect the queue. If
core-eventsswaps to a non-Payload bus (Kafka, NATS),IJobQueueis unaffected.
7. Boundary rules and ESLint
Three new ESLint rules in @repo/core-eslint:
- No handler re-exports. Files matching
**/events/handlers/*.handler.tsmay not appear in anyexport fromorexport *statement in anyindex.tsor barrel file. Custom rule, ~20 LOC. Enforces Rule E1. - No direct
payload.jobsoutside the integration layer.payload.jobsmember access is forbidden outside**/integrations/cms/jobs/**andpackages/core-shared/src/jobs/**. Use cases / controllers / repositories / event handlers must useIJobQueue. Custom rule, ~15 LOC. - R40 allowlist confirmation. The existing R40 rule (no
@sentry/*in feature code) is verified to coverevents/,events/handlers/, andjobs/— they are not added to the allowlist.
The existing five-tag boundary system already covers everything else:
feature → core-events(allowed: feature → core)feature → core-shared/jobs(allowed: feature → core)feature consumer → @repo/<feature publisher>for event contracts (allowed only for the root subpath, which is contracts-only per R18–R21)core-events → core-shared(allowed: core → core)core-eventscannot import from any feature (allowed: core forbids feature)
No new tag, no new package-level rule. Only the three custom-rule additions above.
8. Testing
@repo/core-testing/instrumentation gains two recordings:
// packages/core-testing/src/instrumentation/recording-event-bus.ts
export class RecordingEventBus implements IEventBus {
readonly published: { name: string; payload: unknown }[] = [];
private handlers = new Map<string, EventHandler<unknown>[]>();
async publish<T>(d: EventDescriptor<string, z.ZodType<T>>, payload: T): Promise<void> {
d.schema.parse(payload);
this.published.push({ name: d.name, payload });
for (const h of this.handlers.get(d.name) ?? []) await h(payload);
}
subscribe<T>(d: EventDescriptor<string, z.ZodType<T>>, h: EventHandler<T>): void {
const arr = this.handlers.get(d.name) ?? [];
arr.push(h as EventHandler<unknown>);
this.handlers.set(d.name, arr);
}
}
// packages/core-testing/src/instrumentation/recording-job-queue.ts
export class RecordingJobQueue implements IJobQueue {
readonly enqueued: { taskSlug: string; input: unknown; options?: { runAt?: Date } }[] = [];
async enqueue<T>(taskSlug: string, input: T, options?: { runAt?: Date }) {
this.enqueued.push({ taskSlug, input, options });
return { jobId: `recording-${this.enqueued.length}` };
}
}
| Test | Strategy |
|---|---|
| Publisher use case publishes the right event | Inject RecordingEventBus, assert bus.published[0] matches. |
| Consumer handler does its job | Call factory with mocks, pass an event payload directly. No bus involved. |
| Subscription is wired | One per-feature integration test: instantiate InMemoryEventBus, run bindDevSeed<Feature>(bus, queue), publish, assert handler side effect. |
| Job factory does its job | Identical to a use-case test. |
| Use case enqueues a job | Inject RecordingJobQueue, assert queue.enqueued[0] matches. |
| Schema enforcement at the bus | Pass an invalid payload; assert publish throws ZodError, captured by the publisher's withCapture. |
The bindDevSeed<Feature> signature gains optional bus and queue parameters (defaulted to fresh in-memory instances) so feature integration tests can substitute recordings.
9. Generators
Two new Plop generators in turbo/generators/config.ts, alongside the existing feature generator. Both augment an existing feature package (rather than creating a new one) using a mix of Plop add and modify actions.
9.1 The anchor-comment protocol
A new convention introduced by this spec: six stable single-line anchor comments, each marking an insertion point for a generator. Pre-existing features and the feature generator template are updated to include the anchors as no-op comments, so all features have them from day one regardless of whether they currently use events or jobs.
| Anchor | File | Inserted by |
|---|---|---|
// <gen:events> |
src/index.ts |
gen event publish |
// <gen:event-handler-symbols> |
src/di/symbols.ts |
gen event consume |
// <gen:event-handlers> |
src/di/bind-production.ts, bind-dev-seed.ts |
gen event consume |
// <gen:job-tasks> |
src/integrations/cms/index.ts |
gen job |
// <gen:job-symbols> |
src/di/symbols.ts |
gen job |
// <gen:jobs> |
src/di/bind-production.ts, bind-dev-seed.ts |
gen job |
Files without the required anchor cause the generator to abort with a clear missing // <gen:xxx> anchor in <file> error before writing partial output.
9.2 pnpm turbo gen event
A single generator with a mode prompt. Prompts and validation:
| Prompt | Validation | Used for |
|---|---|---|
mode |
publish | consume |
Branch logic |
feature |
kebab-case, packages/<feature>/src/ must exist |
Where the file is generated |
event |
dotted-kebab past tense (user.signed-up) |
Event slug — full name becomes <feature>.<event> (publish) or <publisher>.<event> (consume) |
publisher (consume only) |
kebab-case, packages/<publisher>/src/events/<event>.event.ts must exist |
Import path for the contract |
Publish mode emits:
packages/<feature>/src/events/<event>.event.ts(Zod schema +defineEvent; schema body is az.object({}).strict()stub)packages/<feature>/src/events/<event>.event.test.tsmodifypackages/<feature>/src/index.ts— re-export the contract behind// <gen:events>
It does not modify any use case. Wiring bus: IEventBus into a use-case factory is a manual edit because the user picks which use case publishes; the printed next-steps block walks through that edit.
Consume mode emits:
packages/<feature>/src/events/handlers/on-<publisher>-<event-kebab>.handler.tspackages/<feature>/src/events/handlers/on-<publisher>-<event-kebab>.handler.test.tsmodifypackages/<feature>/src/di/symbols.ts— addIOn<...>Handlersymbol behind// <gen:event-handler-symbols>modifypackages/<feature>/src/di/bind-production.tsandbind-dev-seed.ts— addbus.subscribe(...)block with the span+capture sandwich behind// <gen:event-handlers>
9.3 pnpm turbo gen job
Prompts:
| Prompt | Validation | Used for |
|---|---|---|
feature |
kebab-case, must exist | Where the job lives |
job |
verb-noun kebab (send-welcome-email) |
Slug becomes <feature>.<job> |
inputShape |
void | typed |
z.object({}).strict() (void) or stub with one example field (typed) |
Emits:
packages/<feature>/src/jobs/<job>.job.tspackages/<feature>/src/jobs/<job>.job.test.tspackages/<feature>/src/integrations/cms/jobs/<job>.task.tsmodifypackages/<feature>/src/integrations/cms/index.ts— re-export task (// <gen:job-tasks>)modifypackages/<feature>/src/di/symbols.ts— add job symbol (// <gen:job-symbols>)modifypackages/<feature>/src/di/bind-production.tsandbind-dev-seed.ts— add factory binding withop: "job"sandwich (// <gen:jobs>)
9.4 Idempotency / failure modes
- File collisions (
addactions) fail loudly — Plop's default. Re-running with the same slug gets a clear error. - Modify actions insert at the anchor on every run. Duplicate bindings produce a TypeScript error at the next
pnpm typecheck. Acceptable tradeoff: simple regex inserts, no AST manipulation, errors are obvious. - Validation order: existence checks (feature directory, anchor presence in target files, publisher contract for consume mode) run during the
promptsphase so the generator aborts before writing partial output.
9.5 What the generators do NOT do
Stays out of scope (printed in the next-steps block instead):
- Modifying the publisher use case to inject the bus (publish mode).
- Wiring custom dependencies into the handler / job factory (user edits the constructor signature manually).
- Filling in the Zod schema body or Payload
inputSchemafield config beyond a stub. - Cron schedule definition (lives in
core-cms'sbuildConfig, manual edit). - Aggregator wiring (the existing
bindAll()inapps/web-next/src/server/bind-production.tsalready handles per-feature binders; no per-event/per-job edits needed there).
9.6 Existing-feature retrofit
The five existing features (auth, blog, media, marketing-pages, navigation) and the feature generator template all gain the six anchor comments as no-op single-line comments in the appropriate files. Pure additive comment-only edits — no behavioral change. One implementation-plan task per feature plus one for the template.
10. Documentation impact
Files updated as part of this spec's implementation:
AGENTS.md— new section under Per-Package Conventions for events and jobs (rules E0/E1/J0, folder layout, sandwich tags,bindAllrule, lint summary). New row in the Specification & Guides table.CLAUDE.md— Quick Start gainspnpm turbo gen event/pnpm turbo gen job. Read First gains a link todocs/guides/events-and-jobs.md(new). Key Conventions gains short bullets for E0, E1, J0.docs/guides/events-and-jobs.md— new long-form guide covering "publish an event", "consume an event", "add a job", with step-by-step generator-plus-manual-wiring walkthroughs.docs/guides/scaffolding-a-feature.md— two new sections at the bottom referencing the new generators and the events/jobs guide.docs/decisions/adr-015-events-and-jobs.md— new ADR distilling this spec's decisions and rules.docs/architecture/vertical-feature-spec.md— the deferred lines at:260and:662get updated to point at this spec instead of saying "deferred".
11. Out of scope (deferred until needed)
Each gets a one-line "deferred until needed; will be its own ADR" note in ADR-015:
- Multi-task workflows. Payload's
WorkflowConfig(composing tasks with branching/retry logic). Defer until one feature has a real multi-step workflow. - Saga / orchestration. Cross-feature multi-step processes with compensating actions. Defer until a real saga appears.
- Exactly-once delivery semantics. Current design is at-least-once for the Payload-jobs bus (each subscriber gets its own task; retries possible). If at-most-once or exactly-once is ever required, that is a new ADR with a new bus implementation.
- Event versioning. Schema evolution strategy (v1 / v2 event shapes, parallel publishing, consumer migration). Defer until first breaking-change need.
- Subscriber dynamic discovery. Currently
bus.subscribeis called at app boot inside each consumer'sbind-production. A feature added at runtime (plugin model) cannot subscribe. Defer. pnpm turbo gen feature --with-events --with-jobsflags. The Phase-1featuregenerator stays single-purpose. If repeated patterns emerge across features, revisit.
12. Decisions locked at this stage
These are the irreversible-feeling design calls — flagged here so reviewers spot any disagreement before implementation begins:
- Both events and jobs in one ADR (ADR-015), not split. They share machinery (factory + sandwich,
bindAllrule, recording-test pattern). Splitting would force cross-references on every page. - Rule E0: no in-feature events. The bus is for cross-feature decoupling only. In-feature reactions are direct use-case calls.
@repo/core-eventsis a new package, not a fold-in tocore-shared. Reasoning in § 3.1.IJobQueuelives incore-shared/jobs/. Reasoning in § 6.4.- In-memory bus swallows handler errors by default. Reasoning in § 6.1.
- Handler files use
on-<publisher>-<event-kebab>.handler.ts. Publisher prefix is mandatory. - Generators for event (publish/consume) and job are in scope. Anchor-comment protocol is the convention.
- Existing-feature retrofit is part of the implementation plan. All five features gain the six anchor comments as no-op edits.
13. Success criteria
The implementation is complete when:
pnpm turbo gen event publishandpnpm turbo gen event consumerun cleanly againstauthandmarketing-pages, producing files that pass lint + typecheck + tests on first generation.pnpm turbo gen jobruns cleanly againstmarketing-pages, producing files that pass lint + typecheck + tests on first generation.- A single end-to-end test in
apps/web-nextproves the cross-feature flow: a sign-up triggers amarketing-pagesjob that asserts a side effect against a recording mailer. bindAll()correctly swaps the bus and queue between in-memory and Payload-backed based onUSE_DEV_SEED/NODE_ENV, verified by a feature integration test in each mode.- All five existing features have the six anchor comments (verified by a tiny CI grep test).
AGENTS.md,CLAUDE.md,docs/guides/events-and-jobs.md,docs/guides/scaffolding-a-feature.md, anddocs/decisions/adr-015-events-and-jobs.mdare written and cross-linked.- All three new ESLint rules pass against the touched packages and reject deliberately-broken fixtures in
core-eslint's test suite.