Files
agentic-dev/docs/superpowers/specs/2026-05-08-events-and-jobs-design.md
Danijel Martinek c531b81544 docs(adr-015): cross-feature events and background jobs
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.
2026-05-08 17:05:05 +02:00

34 KiB
Raw Blame History

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 → feature imports (R6 in AGENTS.md). When auth should trigger work in marketing-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.ts is re-exported from the package root (already where contracts live per R18R21). A feature's events/handlers/*.handler.ts is wired only inside that feature's own bind-production / bind-dev-seed and 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-shared owns primitives (Zod, env, instrumentation, tRPC init). core-events owns infrastructure.
  • The Payload-jobs-backed bus implementation depends on payload. core-shared does 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 file user-signed-up.event.ts defines the event named auth.user.signed-up.
  • Event handler file name: on-<publisher>-<event-kebab>.handler.ts where <event-kebab> matches the publisher's contract file (e.g. on-auth-user-signed-up.handler.ts consumes auth.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 (R41R44 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 role safeParse plays 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:

  1. Mirrors production semantics — PayloadJobsEventBus is async; a publisher in prod never sees a downstream handler error, so dev should match.
  2. Per-handler sandwiches already capture and report errors via logger.captureException. The publisher does not need to re-handle them.
  3. Tests that want to assert handler failure pass failFast: true to 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 (any NODE_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

  • IJobQueue works standalone. A feature can have jobs without ever publishing or subscribing to events. Putting the queue in core-shared keeps simple cases simple — features only depend on core-events if they actually use the bus.
  • The bus depends on the queue (in production). PayloadJobsEventBus enqueues subscriber tasks via IJobQueue. core-events therefore depends on core-shared — the existing direction (core may depend on core).
  • A future bus replacement does not affect the queue. If core-events swaps to a non-Payload bus (Kafka, NATS), IJobQueue is unaffected.

7. Boundary rules and ESLint

Three new ESLint rules in @repo/core-eslint:

  1. No handler re-exports. Files matching **/events/handlers/*.handler.ts may not appear in any export from or export * statement in any index.ts or barrel file. Custom rule, ~20 LOC. Enforces Rule E1.
  2. No direct payload.jobs outside the integration layer. payload.jobs member access is forbidden outside **/integrations/cms/jobs/** and packages/core-shared/src/jobs/**. Use cases / controllers / repositories / event handlers must use IJobQueue. Custom rule, ~15 LOC.
  3. R40 allowlist confirmation. The existing R40 rule (no @sentry/* in feature code) is verified to cover events/, events/handlers/, and jobs/ — 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 R18R21)
  • core-events → core-shared (allowed: core → core)
  • core-events cannot 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 a z.object({}).strict() stub)
  • packages/<feature>/src/events/<event>.event.test.ts
  • modify packages/<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.ts
  • packages/<feature>/src/events/handlers/on-<publisher>-<event-kebab>.handler.test.ts
  • modify packages/<feature>/src/di/symbols.ts — add IOn<...>Handler symbol behind // <gen:event-handler-symbols>
  • modify packages/<feature>/src/di/bind-production.ts and bind-dev-seed.ts — add bus.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.ts
  • packages/<feature>/src/jobs/<job>.job.test.ts
  • packages/<feature>/src/integrations/cms/jobs/<job>.task.ts
  • modify packages/<feature>/src/integrations/cms/index.ts — re-export task (// <gen:job-tasks>)
  • modify packages/<feature>/src/di/symbols.ts — add job symbol (// <gen:job-symbols>)
  • modify packages/<feature>/src/di/bind-production.ts and bind-dev-seed.ts — add factory binding with op: "job" sandwich (// <gen:jobs>)

9.4 Idempotency / failure modes

  • File collisions (add actions) 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 prompts phase 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 inputSchema field config beyond a stub.
  • Cron schedule definition (lives in core-cms's buildConfig, manual edit).
  • Aggregator wiring (the existing bindAll() in apps/web-next/src/server/bind-production.ts already 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, bindAll rule, lint summary). New row in the Specification & Guides table.
  • CLAUDE.md — Quick Start gains pnpm turbo gen event / pnpm turbo gen job. Read First gains a link to docs/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 :260 and :662 get 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.subscribe is called at app boot inside each consumer's bind-production. A feature added at runtime (plugin model) cannot subscribe. Defer.
  • pnpm turbo gen feature --with-events --with-jobs flags. The Phase-1 feature generator 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:

  1. Both events and jobs in one ADR (ADR-015), not split. They share machinery (factory + sandwich, bindAll rule, recording-test pattern). Splitting would force cross-references on every page.
  2. Rule E0: no in-feature events. The bus is for cross-feature decoupling only. In-feature reactions are direct use-case calls.
  3. @repo/core-events is a new package, not a fold-in to core-shared. Reasoning in § 3.1.
  4. IJobQueue lives in core-shared/jobs/. Reasoning in § 6.4.
  5. In-memory bus swallows handler errors by default. Reasoning in § 6.1.
  6. Handler files use on-<publisher>-<event-kebab>.handler.ts. Publisher prefix is mandatory.
  7. Generators for event (publish/consume) and job are in scope. Anchor-comment protocol is the convention.
  8. 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 publish and pnpm turbo gen event consume run cleanly against auth and marketing-pages, producing files that pass lint + typecheck + tests on first generation.
  • pnpm turbo gen job runs cleanly against marketing-pages, producing files that pass lint + typecheck + tests on first generation.
  • A single end-to-end test in apps/web-next proves the cross-feature flow: a sign-up triggers a marketing-pages job that asserts a side effect against a recording mailer.
  • bindAll() correctly swaps the bus and queue between in-memory and Payload-backed based on USE_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, and docs/decisions/adr-015-events-and-jobs.md are 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.