Files
agentic-dev-template/docs/guides/events-and-jobs.md
Danijel Martinek 89d47cce5c docs: strip dead docs/superpowers/ refs across ADRs + guides + glossary
The docs/superpowers/{specs,plans}/ directory was archived to .archive/
in an earlier session (and .archive/ is gitignored). Every md link
into that path is now a broken reference for anyone consuming the
template fresh.

Stripped:
  - ADR-011: **Spec:** header line
  - ADR-015: **Spec:** + **Plan:** header lines
  - ADR-016: **Spec:** + **Plan:** header lines + footer "Spec —"
    bullet (the design rationale is captured in the ADR body itself)
  - ADR-017: **Spec:** + **Plan:** header lines
  - ADR-018: **Spec:** + **Plan:** header lines
  - guides/realtime.md: inline "the full spec" link + footer
    [Spec] entry (folded its description into the ADR-016 entry)
  - guides/events-and-jobs.md: inline "the full spec" link
  - architecture/vertical-feature-spec.md: stale "Deleted" subsection
    referencing docs/superpowers/plans/*

Updated:
  - glossary.md "PRD" entry: clarified status flow now matches the
    shipped pnpm work prd-ship lifecycle (draft -> in-review ->
    approved -> shipped); removed the parenthetical pointing at
    docs/superpowers/specs/ as a definition of "spec"
  - glossary.md "spec" flagged-ambiguity: rewritten to reflect that
    durable design lives in ADRs (docs/decisions/adr-NNN-*.md) and
    implementation seeds live in PRDs (docs/work/prds/*.prd.md) —
    "spec" should be avoided in this template

Preserved (legitimate refs to the SuperPowers plugin, not the dir):
  - agent-first-workflow-and-conformance.md mentions of
    `superpowers:brainstorming` — these reference the external
    plugin skill, not a file in the repo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:00:11 +02:00

10 KiB

Events and Jobs

Walkthrough for adding cross-feature events and background jobs to a feature. For the architectural rationale, see ADR-015.

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 // <gen:*> anchor comments that are present in every feature.

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:

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 // <gen:events> anchor in packages/auth/src/index.ts

Then fill in the schema with the actual fields the event carries:

// 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:

// 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<SignUpOutput> => {
    // ... 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:

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:

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):

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 // <gen:event-handler-symbols>
  • src/di/bind-production.ts — wraps the handler in span+capture, binds to the symbol, and calls bus.subscribe(...) at // <gen:event-handlers>
  • src/di/bind-dev-seed.ts — same as production, identical block
  • src/integrations/cms/index.ts — re-exports the event-task at // <gen:job-tasks> 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):

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:

"@repo/auth": "workspace:*"

Then implement the handler body. The factory shape is (deps) => async (event) => Promise<void>. Inject what the handler needs — typically a job queue if the reaction is deferred:

// 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<void> => {
    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:

onAuthUserSignedUpHandler(queue),

Verify:

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

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 (<gen:job-symbols>, both <gen:jobs>, <gen:job-tasks>).

Fill in the schema and body. Inject the dependencies you need:

// 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<typeof sendWelcomeEmailInputSchema>;
export type ISendWelcomeEmailJob = ReturnType<typeof sendWelcomeEmailJob>;

export const sendWelcomeEmailJob =
  (mailer: IMailerService) =>
  async (input: SendWelcomeEmailInput): Promise<void> => {
    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:

sendWelcomeEmailJob(mailer),

For dev-seed, register the slug with the InMemoryJobQueue so enqueue actually fires:

if (
  "register" in queue &&
  typeof (queue as { register?: unknown }).register === "function"
) {
  (
    queue as {
      register: (slug: string, h: (input: unknown) => Promise<void>) => void;
    }
  ).register("marketing-pages.send-welcome-email", async (input) => {
    const wrapped = marketingPagesContainer.get<ISendWelcomeEmailJob>(
      MARKETING_PAGES_SYMBOLS.ISendWelcomeEmailJob,
    );
    await wrapped(input as SendWelcomeEmailInput);
  });
}

Production skips this — the generated Payload task in integrations/cms/jobs/<job>.task.ts resolves the wrapped job from the per-feature container at runtime.

Edit Payload's inputSchema in the generated <job>.task.ts to match your Zod schema (Payload's inputSchema is field-config, not a TypeScript shape):

inputSchema: [
  { name: "userId", type: "text", required: true },
  { name: "email", type: "email", required: true },
],

Verify:

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:events> gen event publish
src/di/symbols.ts // <gen:event-handler-symbols> gen event consume
src/di/symbols.ts // <gen:job-symbols> gen job
src/di/bind-production.ts // <gen:event-handlers> gen event consume
src/di/bind-production.ts // <gen:jobs> gen job
src/di/bind-dev-seed.ts // <gen:event-handlers> gen event consume
src/di/bind-dev-seed.ts // <gen:jobs> gen job
src/integrations/cms/index.ts // <gen:job-tasks> 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.