Dependency Injection Across the Boundary
`@AtollService`: a NestJS provider whose methods run in a pool
Problem. In a NestJS app you're usually forced to choose between clean DI and off-thread execution — worker code lives outside the provider graph, reached through bespoke client calls.
Fix.
@AtollService/@AtollTaskplusAtollModule.registerPoolandrunAtollWorker: the same class is an injectable on the API thread and a real provider inside the worker, withwithSharedBuffersharing buffers across pools.
NestJS is where this question matters most, because Nest's value proposition is that the provider graph is the architecture. If worker code lives outside that graph — reached through a bespoke client with its own error conventions — you've given up the reason you chose the framework.
So the test to judge this by: can a service run in a worker while its consumers inject it like nothing changed?
The service-level facade
One class-level decorator marks every method for offload — the class is the interop surface, and it can use injected dependencies and shared memory like any provider:
// report.service.ts — the facade
@Injectable()
@AtollService({ pool: 'reports' })
export class ReportService {
constructor(
@Inject(ScanTelemetry) private readonly telemetry: ScanTelemetry,
) {}
/** Full-table aggregate over the shared buffer — runs in the worker. */
execSummary() {
this.telemetry.note('execSummary'); // the worker's own instance
const conn = incidentsMemory.lists.incidents;
const bySeverity = [0, 0, 0, 0];
for (let i = 0; i < conn.recordCount; i++) {
conn.readAt(i, rec, ['severity', 'status', 'region', 'customers']);
bySeverity[rec.severity]++;
// ...same million-row scan the browser demos run
}
return { total: conn.recordCount, bySeverity /* … */ };
}
/** Proof of context — this worker's threadId + its injected state. */
workerInfo() {
return { threadId, scans: this.telemetry.scans };
}
}The consumer can't tell — and that's the point. DashboardService is a
plain provider with zero Atoll imports:
// dashboard.service.ts — a service calls a service
@Injectable()
export class DashboardService {
constructor(
@Inject(ReportService) private readonly reports: ReportService,
) {}
/** One response composed from two pool dispatches. */
async overview() {
const [summary, worker] = await Promise.all([
this.reports.execSummary(), // EXECUTE_TASK → 'reports' pool
this.reports.workerInfo(), // EXECUTE_TASK → 'reports' pool
]);
return { ...summary, generatedBy: worker };
}
}Every call dispatches as a ReportService.method task. That zero-import
property is what separates a facade from a leak with better branding.
The symmetric half is the module — it registers the pool on the API thread and boots inside the worker, so it's the boundary on both sides:
// facade.module.ts
@Module({
imports: [
AtollModule.registerPool({
name: 'reports',
// message-only pool — borrows the incidents pool's buffer,
// resolved lazily per spawn so respawns get it too
worker: withSharedBuffer(
() => new Worker(new URL('./facade.worker.ts', import.meta.url)),
() => getAtollPool('incidents')?.sharedBuffer,
),
poolSize: 2,
}),
],
providers: [ScanTelemetry, ReportService, DashboardService],
})
export class FacadeAtollModule {}
// facade.worker.ts — the whole worker entry
await bindSharedBuffer(); // the incidents pool's buffer
await runAtollWorker(FacadeAtollModule);On the API thread, registerPool spawns the workers and ReportService
resolves to the dispatching proxy. In the worker, runAtollWorker boots
a real Nest application context over the same module — the pool
provider resolves to nothing and the @AtollService bodies execute on
the DI'd instance, so ScanTelemetry is genuinely that worker's own.
withSharedBuffer/bindSharedBuffer do the other trick: a message-only
pool binds a different pool's buffer on spawn — the reports pool reads
the same million incidents the housed-API pool serves. One buffer, two
pools, zero copies.
@AtollTask remains for per-method dispatch when only some of a class
belongs off-thread.
Housed APIs — routes that only exist in workers
The facade pushes method calls across the boundary. Housed APIs push
routes: a URL subtree whose controllers exist only inside workers.
Each housed worker boots a full Nest app on an internal port — no
runAtollWorker, it serves HTTP instead of tasks:
// housed.worker.ts — a dedicated HTTP worker, not a task worker
await bindSharedBuffer(); // same incidents buffer, bound before boot
const app = await NestFactory.create(HousedApiModule);
await app.init();
serveHttp(app.getHttpServer(), { listen: 0 }); // announces HTTP_PORT upThe worker-side module is a plain Nest module — controllers, providers,
no registerPool (pools only spawn on the main thread). The controllers
are ordinary too: @Controller('api/housed/incidents') with @Get
routes that scan the shared buffer directly and stamp threadId on each
response. Injecting IncidentsAnalytics — the same @AtollService
class the API thread RPCs into — runs the trick in reverse: inside a
worker the pool registry is empty, so its methods execute their real
bodies on this worker's own instance. Per-worker state is genuinely
per-worker.
On the main thread, the 'housed' pool registers exactly like
'reports' — message-only, withSharedBuffer off the incidents pool —
and the app proxies the prefix:
// main.ts — /api/housed/* forwards into the pool's internal listeners
const pool = getAtollPool('housed');
const tracker = workerHttpPorts(pool); // follows HTTP_PORT announcements
app.use('/api/housed', proxyToWorker({
pool,
tracker,
to: '/api/housed', // express stripped the mount — restore it in-worker
worker: (workers) => workers[cursor++ % workers.length], // round-robin
}));The lifecycle is the part worth noting: a worker announces its listener
with an HTTP_PORT handshake (workerHttpPorts tracks them, with a
query covering announcements that raced the tracker), the worker:
selector can round-robin or pin to a slot, and a respawned worker
re-announces and takes its routes back automatically. WebSockets get the
same treatment through proxyUpgradeToWorker, and on Node ≥ 26
createHttpCluster adds a second listener where the main thread hands
each accepted socket to a worker unparsed — per-connection, so it
lives on its own port rather than sharing the app's.
The discipline worth taking from this even if you never install the
package: the class is the contract; where its body runs is
configuration, not refactor. Same idea as islandComponent, same idea
as the worker client — one pattern, every layer of the framework.
Source: the NestJS bindings guide — service
facades and housed APIs — and the runnable modules in
examples/nestjs/src/facade/ and examples/nestjs/src/housed/.