Naalya Handbook
Recipes

Add a Background Job

Offload slow work to the worker app with a typed BullMQ queue — define the contract, enqueue from a service, add the processor.

Some work has no business happening inside an HTTP request. Sending an email, writing an audit entry, syncing with an external API — these are slow, they can fail, and the user shouldn't wait on them. The fix is a background job: the request hands the work to a queue and returns immediately, and a separate process picks it up later.

The queue here is BullMQ, a Redis-backed job queue. Two roles share it. The producer is the code that adds a job — almost always a service inside the server app. The consumer (or processor) is the code that runs it — usually the worker app, sometimes the audit app. They live in different processes and never call each other directly; they only agree on the shape of the payload, and that shared shape is the whole trick.

That shared shape lives in @app/shared — a library both apps import. Define the payload type there once, and the producer and processor are type-checked against the same contract. This page walks the most common task end to end: adding a new email type to the existing email queue, then a short note on standing up a brand-new queue.

The mental model: contract, producer, processor

Every background job is three things in three places — a payload type in @app/shared, a producer that enqueues it (a server service), and a processor that runs it (the worker or audit app). Get those three to agree and the job flows. The rest is wiring.

For the bigger picture of how queues fit alongside the RabbitMQ message channel, see Queues & Messaging.


The example

The email queue (EMAIL_QUEUE, value 'email') already exists. Its producer is EmailService in the server; its processor is EmailProcessor in the worker. Adding a new kind of email means extending the existing contract rather than building anything new — the perfect first job. Say we want to email a guardian when their child's term report is ready.

The payload is a discriminated union — a TypeScript pattern where every variant carries a literal type field, so the compiler can tell the variants apart and give each its own typed data. Add your variant to that union and TypeScript pulls you through every place that must change.


Step 1: Extend the contract in @app/shared

First, teach the shared library about the new email. Open the email types file and add an enum value, an interface for the payload, and the new variant to the union.

libs/shared/src/email/email.types.ts
export enum EmailType {
  // ...existing variants...
  TERM_REPORT_READY = 'TERM_REPORT_READY', // new
}

interface TermReportReadyEmailPayload extends BaseEmailPayload {
  type: EmailType.TERM_REPORT_READY;
  data: { firstName: string; termName: string; reportUrl: string };
}

export type EmailJobPayload =
  | /* ...existing variants... */
  | TermReportReadyEmailPayload; // add to the union

BaseEmailPayload already supplies the common fields — to, subject, an optional campusId — so your interface only declares what's unique: the type discriminator and a typed data bag. Because EmailJobPayload is the union both sides import, forgetting to add your variant to it means the producer can't pass it and the processor never sees it.

Add to the union, or it doesn't exist

The new interface is invisible until it appears in the EmailJobPayload union. The enum value and the interface alone won't type-check at the call site — the union is the contract both the producer and processor are written against.


Step 2: Handle it in the processor

Next, tell the processor what to do with the new type. EmailProcessor.getTemplate() is a switch on payload.type that returns a React Email template — and it's exhaustive: the default branch has a never check, so the moment you add an EmailType the compiler refuses to build until every case is handled.

Add a case for your type in the worker's processor.

apps/worker/src/processors/email.processor.ts
case EmailType.TERM_REPORT_READY:
  return createElement(TermReportEmail, {
    firstName: payload.data.firstName,
    termName: payload.data.termName,
    reportUrl: payload.data.reportUrl,
  });

Then add the matching template under emails/ — the quickest start is to copy emails/otp.tsx, the one fully built template today, and adjust its props.

The never check is your friend

That exhaustive switch is deliberate. It turns "I forgot to handle the new email" from a silent runtime gap into a compile error — the build literally won't pass until your case exists. Let the type checker do the remembering.


Step 3: Enqueue it from the server

Now the producer. EmailService is @Global(), so any module can inject it without importing anything. Call send with your fully-typed payload — and that's the whole producer side.

apps/server/src/app/.../some.service.ts
this.emailService.send({
  type: EmailType.TERM_REPORT_READY,
  to: guardian.email,
  subject: 'Your child’s term report is ready',
  data: { firstName: guardian.firstName, termName, reportUrl },
});

Under the hood send does one thing — emailQueue.add(payload.type, payload) — using the EmailType value as the job name and the payload as the job data. Retries are automatic: the global Bull config sets attempts: 3, so a job that throws is retried up to three times before it's marked failed.

Enqueueing is fire-and-forget — never block on it

send returns void and swallows-then-logs any enqueue error. That's intentional: a user's API request must not fail just because Redis hiccupped. Don't await a result you can't get, and don't make a request's success depend on the job landing.


Step 4: Test the producer

A producer test asserts one thing — that calling the service enqueues the right job name with the right payload. Mock the queue and check the add call, mirroring email.service.spec.ts.

apps/server/src/app/email/tests/email.service.spec.ts
service.send(payload);
expect(mockQueue.add).toHaveBeenCalledWith(EmailType.TERM_REPORT_READY, payload);

You don't test the processor and the producer together — they run in different processes. The contract in @app/shared is what guarantees they line up. See Testing for how queues are mocked.


A new queue

When the work isn't email or audit, you create your own queue. It's the same three-part shape — contract, producer, processor — just built from scratch instead of extended. Mirror EMAIL_QUEUE throughout.

Define the contract

Add a queue-name constant and the payload type(s) in @app/shared, and export both from the library barrel so both apps can import them.

libs/shared/src/index.ts
export const REPORT_QUEUE = 'report';
export interface ReportJobPayload {
  /* the typed job data */
}

Register the producer (server)

In the owning server module, register the queue with BullModule and surface it in Bull Board (the dashboard at /queues). Then inject it into a service with @InjectQueue and add jobs the same fire-and-forget way EmailService does.

apps/server/src/app/report/report.module.ts
imports: [
  BullModule.registerQueue({ name: REPORT_QUEUE }),
  BullBoardModule.forFeature({ name: REPORT_QUEUE, adapter: BullMQAdapter }),
],
// in the service:
constructor(@InjectQueue(REPORT_QUEUE) private readonly queue: Queue) {}

Add the processor

In the consuming app, write a class that extends WorkerHost and is decorated @Processor(REPORT_QUEUE), then register it. Registration is two lines in that app's module: the queue in imports, the processor in providers — exactly how the worker wires up EmailProcessor.

apps/worker/src/worker.module.ts
imports: [
  // ...
  BullModule.registerQueue({ name: REPORT_QUEUE }),
],
providers: [
  // ...
  ReportProcessor,
],

The consuming app already holds the global Bull/Redis connection, so you don't reconfigure Redis — just register the queue and the processor.

Pick the right app: worker for general work, audit for audit writes

The worker app is the default home for background jobs. The audit app is reserved specifically for audit-log writes — don't add unrelated processors there. One exception exists in the codebase: the Microsoft-sync processor runs inside the server itself because it depends on many server modules. Treat that as the rare special case, not the pattern.


Verify

Run the test suite, then boot all three apps together and watch the job flow through the dashboard. Trigger the producing code (hit the endpoint, run the command) and find your job at /queues.

terminal
pnpm test
pnpm dev:all   # runs server + worker + audit together
# then trigger the producer and watch the job at /queues (Bull Board)

If a job is enqueued but never runs, the usual cause is a missing or unregistered processor — confirm the consuming app lists it in providers and registers the queue in imports.


Where to go next

On this page