NestJS — service facades

@AtollService turns a provider class into the interop surface itself — the service-level analog of the frontend islandComponent facade. Inject it like any provider, call its methods, and every call dispatches to the worker pool. The main↔worker boundary disappears at the call site.

The facade — the class is the contract

One class-level @AtollService({ pool }) marks every prototype method for offload under ClassName.method task ids — no @AtollTask per method, no dispatch code in the class. The same file loads on both sides: on the API thread the decorated methods are proxies; inside a pool worker runAtollWorker binds them to the DI-resolved instance and the bodies execute.

facade/report.service.tsbackend
// src/facade/report.service.ts — the service class IS the facade.
// One class-level @AtollService marks EVERY method for offload under
// ReportService.<method> task ids — no per-method decorators, no
// dispatch code anywhere in the class.
import { Inject, Injectable } from '@nestjs/common';
import { threadId } from 'node:worker_threads';
import { AtollService } from '@atolljs/nestjs/decorators';
import { incidentsMemory, REGIONS, SEVERITIES, STATUSES,
         type Incident } from '@atolljs/incidents';
import { ScanTelemetry } from '../shared/scan-telemetry.service';

const OPEN = STATUSES.indexOf('open');
const rec = {} as Incident;

@Injectable()
@AtollService({ pool: 'reports' })
export class ReportService {
  // Real DI — resolves inside the worker's own Nest context.
  constructor(@Inject(ScanTelemetry) private readonly telemetry: ScanTelemetry) {}

  execSummary() {
    this.telemetry.note('execSummary');
    const conn = incidentsMemory.lists.incidents;
    // …one zero-copy scan of 1M shared records → aggregated object…
    return { total: conn.recordCount, /* bySeverity, topOpenRegion, … */ };
  }

  regionReport(region: string) { /* parameterized scan → object */ }

  workerInfo() {
    // Proof of context: threadId > 0 in a worker, telemetry is
    // this worker's own DI'd instance.
    return { threadId, scans: this.telemetry.scans, lastTask: this.telemetry.lastTask };
  }
}

The consumer — service to service

This is the piece that makes it a service-level facade: a normal injectable composes worker calls without importing anything atoll-shaped. DashboardService doesn't know — and can't know — that ReportService methods run in another thread. That's the test: if the consumer had to know, it wouldn't be a facade.

facade/dashboard.service.tsbackend
// src/facade/dashboard.service.ts — a plain main-thread service with
// ZERO atoll imports. It injects the facade like any provider; every
// call it makes is a worker dispatch under the hood.
import { Inject, Injectable } from '@nestjs/common';
import { ReportService } from './report.service';

@Injectable()
export class DashboardService {
  constructor(@Inject(ReportService) private readonly reports: ReportService) {}

  async overview() {
    // One HTTP response composed from two EXECUTE_TASK dispatches —
    // the consumer sees ordinary async methods.
    const [summary, worker] = await Promise.all([
      this.reports.execSummary(),
      this.reports.workerInfo(),
    ]);
    return { ...summary, generatedBy: worker };
  }
}

The boundary — module + worker entry

One module is imported by the main app and bootstrapped inside each worker — the pool registers on the main side, resolves to null in workers, and the providers resolve on both sides with per-worker DI. The pool is message-only and shares the incidents buffer the same way the housed pool does.

facade/facade.module.tsbackend
// src/facade/facade.module.ts — the application boundary, imported by
// the main app AND bootstrapped inside each 'reports' worker. The pool
// is message-only: it shares the incidents pool's buffer via
// withSharedBuffer rather than allocating a second one — two pools,
// one buffer, so the scans below read the same 1M records.
import { Module } from '@nestjs/common';
import { Worker } from 'node:worker_threads';
import { AtollModule, getAtollPool } from '@atolljs/nestjs';
import { withSharedBuffer } from '@atolljs/node';

@Module({
  imports: [
    AtollModule.registerPool({
      name: 'reports',
      worker: withSharedBuffer(
        () => new Worker(new URL('./facade.worker.ts', import.meta.url)),
        () => getAtollPool('incidents')?.sharedBuffer, // lazy — respawns too
      ),
      poolSize: 2,
    }),
  ],
  providers: [ScanTelemetry, ReportService, DashboardService],
  exports: [ReportService, DashboardService, AtollModule],
})
export class FacadeAtollModule {}

// src/facade/facade.worker.ts — the whole worker entry:
//   import { runAtollWorker } from '@atolljs/nestjs/worker';
//   import { bindSharedBuffer } from '@atolljs/node';
//   import { FacadeAtollModule } from './facade.module';
//   void (async () => {
//     await bindSharedBuffer();              // incidents pool's buffer
//     await runAtollWorker(FacadeAtollModule); // real DI inside the worker
//   })();

The call site — invisible by design

facade/report.controller.tsbackend
// src/facade/report.controller.ts — no atoll imports at all. The
// boundary is invisible end to end: controller → service → facade →
// worker, and back with a plain Promise.
import { Controller, Get, Inject, Param } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { ReportService } from './report.service';

@Controller('api/reports')
export class ReportController {
  constructor(
    @Inject(DashboardService) private readonly dashboard: DashboardService,
    @Inject(ReportService) private readonly reports: ReportService,
  ) {}

  @Get('overview')
  overview() {
    return this.dashboard.overview();   // service→service→worker
  }

  @Get('region/:region')
  region(@Param('region') region: string) {
    return this.reports.regionReport(region); // parameterized dispatch
  }

  @Get('worker')
  worker() {
    return this.reports.workerInfo();   // which worker answered
  }
}

What executes where

Trace GET :3100/api/reports/overview — one HTTP request, two dispatches, zero atoll code on the API side:

  1. API thread — the controller calls dashboard.overview(), a plain injectable.
  2. API thread — DashboardService calls reports.execSummary() and reports.workerInfo(). The class-level decorator has replaced each body: the call becomes EXECUTE_TASK ReportService.execSummary on the reports pool and returns a Promise.
  3. worker — the pool dispatches to the TaskRegistry entry runAtollWorker registered at boot, bound to the worker's own DI-resolved ReportService — its ScanTelemetry is that worker's real instance.
  4. API thread — both Promises resolve with structured-cloned results; the response merges them.

Hit http://localhost:3100/api/reports/worker repeatedly — the two workers alternate and each reports its own threadId and telemetry counters.

Two forms

The contract-less form above offloads every method under ClassName.method ids. The contract form declares the surface explicitly — useful when a class mixes offloadable and local-only methods, or when you want zod validation on the wire:

incidents-rpc.service.tsbackend
// The contract form binds only the methods a ServiceContract declares —
// dispatch happens under the contract's own taskIds (and carries its zod
// schemas for arg/result validation):
@Injectable()
@AtollService(incidentsRpc, { pool: 'incidents' })
export class IncidentsRpc {
  seedIncidents() { /* … */ }          // → task id 'incidents.seedIncidents'
  computeMetrics() { /* … */ }         // → 'incidents.computeMetrics'
  // methods NOT in the contract are untouched — they run wherever
  // they're invoked, on either side
}

Choosing an interop style

StyleCall shapePick when
@AtollService facadeinject(ReportService).execSummary()Service-level interop — consumers stay plain DI clients; the worker class is the contract.
@AtollTask per-methodservice.method() — decorated subsetGranular control: only specific methods offload (see DigestService).
workerClientincidents.computeMetrics()Calling a worker's exported task map directly (contract-package workers).
Housed APIsGET /api/housed/*Whole routes execute in workers — no main-thread call at all.

Try it

The repo's examples/nestjs runs the facade on a dedicated 2-worker reports pool — start it via npm run serve:all, then:

Notes

  • The fallback is local execution, not an error — if no pool named in @AtollService is registered, methods run on the calling thread (the validator warns at boot). Inside workers the registry is always empty, which is exactly why housed controllers can call the same class and get real bodies.
  • Args and results cross postMessage (structured clone) — keep them small; the shared buffer carries the big state.
  • The decorated class file loads on BOTH sides — keep its imports worker-safe (no express/platform-specific deps) and its method bodies free of main-thread-only resources.
  • Facade methods always resolve to a Promise-shaped call — write the bodies as if they ran locally; async or sync both work.