NestJS Module 4 – Modules, dynamic modules and lifecycle
Standalone apps, CLI commands and the REPL
Run Nest providers without HTTP, in scripts built on createApplicationContext(), in an operations CLI with nest-commander, and in the Nest REPL.
What you will learn
- Run Nest providers without an HTTP server with createApplicationContext(), and close the context so that a script ends with the right exit status
- Choose between a script, a CLI command and the REPL for a job that needs an app's providers
- Build an operations CLI with nest-commander commands, required options and exit codes that a scheduler can trust
- Inspect and call providers from the Nest REPL
- Diagnose a script that exits without a message, and keep its error logger
Before you start
What you’ll learn · Before you start
What you will learn
- Run Nest providers without an HTTP server with createApplicationContext(), and close the context so that a script ends with the right exit status
- Choose between a script, a CLI command and the REPL for a job that needs an app's providers
- Build an operations CLI with nest-commander commands, required options and exit codes that a scheduler can trust
- Inspect and call providers from the Nest REPL
- Diagnose a script that exits without a message, and keep its error logger
Before you start
On this page
At 10:30 IST TiffinTrail stops taking lunch orders, and a job must close the day: send each kitchen its orders and move
the late ones to tomorrow. That job needs the API’s OrdersService and none of the API’s HTTP server, and it must end
with an exit status a scheduler can read.
Nest runs its modules without HTTP in application context: What NestFactory.createApplicationContext() returns: an app's modules and providers, built and with their lifecycle hooks run, but no HTTP server, routes or request pipeline. Scripts, workers and CLIs run on it.: scripts, CLI commands and a REPL: A read-eval-print loop: a prompt that runs each line typed and prints the result. Nest's repl() from @nestjs/core opens one on an app's modules, with helpers such as get() and debug().. This lesson closes the day with a script, then builds an operations CLI with nest-commander, opens the REPL on the same modules, and finds out why one script died without a word.
An application context is Nest without the HTTP server
NestFactory.createApplicationContext(AppModule) reads the same modules as NestFactory.create() and builds the same
providers, and it opens no port. The close-day script asks the context for OrdersService, runs closeDay(), prints
the summary and closes the context. The time comes from --at, so that a run can be repeated:
TypeScript (close-day.ts) · ops/src/close-day.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { OrdersController, OrdersModule, OrdersService } from './orders.js';
// node dist/close-day.js --at <ISO time>: closes the lunch day of that instant, as a cron job would at 10:30 IST.
const at = new Date(process.argv[process.argv.indexOf('--at') + 1] ?? '');
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
try {
for (const line of app.select(OrdersModule).get(OrdersService, { strict: true }).closeDay(at)) console.log(line);
// A controller is built too, but no route exists, and calling it directly skips the global interceptor.
console.log(`OrdersController.list() called directly: ${JSON.stringify(app.get(OrdersController).list())}`);
} catch (e) {
console.error((e as Error).message);
process.exitCode = 1;
} finally {
await app.close();
} TypeScript (orders.ts) · ops/src/orders.ts
import { Controller, Get, Injectable, Module, type OnApplicationShutdown, type OnModuleInit } from '@nestjs/common';
export interface Order {
id: number;
kitchenId: string;
/** When the customer placed it, in UTC. */
placedAt: string;
paise: number;
status: 'placed' | 'sent-to-kitchen' | 'moved-to-tomorrow';
}
/** India Standard Time is UTC+05:30 all year. */
const IST_OFFSET_MS = (5 * 60 + 30) * 60_000;
/** The lunch day an instant belongs to, as YYYY-MM-DD in IST. */
export function istDay(at: Date): string {
return new Date(at.getTime() + IST_OFFSET_MS).toISOString().slice(0, 10);
}
/** The lunch cut-off of a day: 10:30 IST, which is 05:00 UTC. */
export function cutOffOf(day: string): Date {
return new Date(Date.parse(`${day}T10:30:00Z`) - IST_OFFSET_MS);
}
/** Stands for the orders table: six orders of one morning, loaded at start-up. */
@Injectable()
export class OrdersRepository implements OnModuleInit, OnApplicationShutdown {
orders: Order[] = [];
onModuleInit() {
this.orders = [
{ id: 301, kitchenId: 'pune-07', placedAt: '2026-09-16T23:40:00Z', paise: 12000, status: 'placed' },
{ id: 302, kitchenId: 'pune-07', placedAt: '2026-09-17T02:15:00Z', paise: 12000, status: 'placed' },
{ id: 303, kitchenId: 'pune-12', placedAt: '2026-09-17T03:05:00Z', paise: 9500, status: 'placed' },
{ id: 304, kitchenId: 'pune-07', placedAt: '2026-09-17T04:20:00Z', paise: 15000, status: 'placed' },
{ id: 305, kitchenId: 'pune-12', placedAt: '2026-09-17T04:59:00Z', paise: 9500, status: 'placed' },
{ id: 306, kitchenId: 'pune-07', placedAt: '2026-09-17T05:07:00Z', paise: 12000, status: 'placed' },
];
console.log(`orders loaded: ${this.orders.length}`);
}
onApplicationShutdown() {
console.log('orders saved, connection closed');
}
}
@Injectable()
export class OrdersService {
constructor(private readonly repo: OrdersRepository) {}
/** Sends the day's orders placed before the cut-off to the kitchens, and moves later ones to the next day. */
closeDay(now: Date): string[] {
if (Number.isNaN(now.getTime())) throw new Error('close-day: --at must be an ISO time such as 2026-09-17T05:00:00Z');
const day = istDay(now);
const cutOff = cutOffOf(day);
if (now < cutOff) return [`${day}: still open, ${Math.round((cutOff.getTime() - now.getTime()) / 60_000)} minutes to the 10:30 IST cut-off`];
const perKitchen = new Map<string, number>();
const moved: number[] = [];
for (const order of this.repo.orders) {
if (order.status !== 'placed' || istDay(new Date(order.placedAt)) !== day) continue;
if (new Date(order.placedAt) < cutOff) {
order.status = 'sent-to-kitchen';
perKitchen.set(order.kitchenId, (perKitchen.get(order.kitchenId) ?? 0) + 1);
} else {
order.status = 'moved-to-tomorrow';
moved.push(order.id);
}
}
return [
`${day}: closed at the 10:30 IST cut-off (${cutOff.toISOString()})`,
...[...perKitchen].map(([kitchen, n]) => ` ${kitchen}: ${n} lunches sent to the kitchen`),
` moved to tomorrow: ${moved.length ? moved.join(', ') : 'none'}`,
];
}
/** The orders of a kitchen placed before the cut-off of a day, as the kitchen's display received them. */
sentToKitchen(kitchenId: string, day: string): Order[] {
const cutOff = cutOffOf(day);
return this.repo.orders.filter(
(o) => o.kitchenId === kitchenId && istDay(new Date(o.placedAt)) === day && new Date(o.placedAt) < cutOff,
);
}
/** Lunches per kitchen for a day, placed before its cut-off. */
lunchesPerKitchen(day: string): Map<string, number> {
const counts = new Map<string, number>();
for (const kitchen of new Set(this.repo.orders.map((o) => o.kitchenId))) counts.set(kitchen, this.sentToKitchen(kitchen, day).length);
return counts;
}
}
@Controller('orders')
export class OrdersController {
constructor(private readonly repo: OrdersRepository) {}
@Get()
list() {
return this.repo.orders.map((o) => o.id);
}
}
@Module({ controllers: [OrdersController], providers: [OrdersRepository, OrdersService], exports: [OrdersService] })
export class OrdersModule {} Step through what the context builds for it, and what it leaves out:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A context, not a server. The script calls NestFactory.createApplicationContext(AppModule). It asks for Nest's container and nothing that listens, so no port is opened.
- The modules are read. Nest scans AppModule and every module it imports, exactly as NestFactory.create() would, so the script sees the same providers as the API.
- Everything is built. Nest builds every provider and controller and runs onModuleInit() and onApplicationBootstrap(). The run printed orders loaded, 6, from the repository's hook.
- Nothing for HTTP. No HTTP server is created and no route is registered. Guards, pipes and interceptors act on requests, so the controller method the script called directly skipped the global interceptor and returned the bare ids.
- The script takes a provider. app.select(OrdersModule).get(OrdersService, { strict: true }) returns the provider of that module, and closeDay() runs on the same code the API uses.
- app.close() lets it end. await app.close() runs the shutdown hooks, so the run printed orders saved, and leaves nothing open, so the process ends and the cron job sees its exit status.
import { session } from '../session.mjs';
// The close-day script, run as a cron job would run it: before the 10:30 IST cut-off, at it, and with a bad time.
session(`
npx tsc -p tsconfig.json
node dist/close-day.js --at 2026-09-17T04:45:00Z
node dist/close-day.js --at 2026-09-17T05:00:00Z
!node dist/close-day.js --at yesterday
`); Output
$ npx tsc -p tsconfig.json $ node dist/close-day.js --at 2026-09-17T04:45:00Z orders loaded: 6 2026-09-17: still open, 15 minutes to the 10:30 IST cut-off OrdersController.list() called directly: [301,302,303,304,305,306] orders saved, connection closed $ node dist/close-day.js --at 2026-09-17T05:00:00Z orders loaded: 6 2026-09-17: closed at the 10:30 IST cut-off (2026-09-17T05:00:00.000Z) pune-07: 3 lunches sent to the kitchen pune-12: 2 lunches sent to the kitchen moved to tomorrow: 306 OrdersController.list() called directly: [301,302,303,304,305,306] orders saved, connection closed $ node dist/close-day.js --at yesterday orders loaded: 6 close-day: --at must be an ISO time such as 2026-09-17T05:00:00Z orders saved, connection closed [exit status 1]
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node close-day.mjs
At 10:15 IST the day was still open, 15 minutes before the cut-off. At 10:30 the script sent 3 lunches to pune-07 and
2 to pune-12 and moved order 306, placed at 10:37 IST, to tomorrow; order 301 counted for the 17th, because the lunch day
is the date in India. orders loaded and orders saved show the context running the module’s lifecycle hooks, at
creation and in app.close(). The documentation tells scripts to end with await app.close() so that the process
exits; the finally block does it even after the bad --at, which set process.exitCode to 1. Two options of
createApplicationContext() matter to every script: logger sets which levels Nest prints, and abortOnError decides
whether a start-up error ends the process.
The controller was built too, but nothing in a context handles requests. The documentation says guards, pipes and
interceptors are not available there, and calling a controller method through app.get() skips a global interceptor:
the run printed the bare ids, without the { data } envelope the API’s interceptor adds.
Key idea
An application context builds every module and provider of an app, runs their lifecycle hooks and opens no port, so a script reuses the API’s services and ends, with its exit status, once it closes the context.
Checkpoint
Check: What an application context builds
A script, a CLI command or the REPL: three doors to the same providers
Standalone apps are what the documentation calls standalone applications, Nest without a network listener, and they
come in three shapes. A script does one job and ends, like the close-day script. CLI commands give a team one program
with many jobs, each with named options and help, such as ops orders replay --kitchen pune-07. The REPL is a prompt
in which a person types calls to providers and sees the results, one line at a time.
| A script | CLI commands with nest-commander | The REPL | |
|---|---|---|---|
| Built on | createApplicationContext() |
CommandFactory.run(), which wraps it |
repl() of @nestjs/core |
| Who runs it | a scheduler or a deploy step | people and schedulers, by name | a developer, by hand |
| Inputs | arguments you parse | options declared on each command | any JavaScript typed at the prompt |
| Fits | one recurring job | a toolbox of operations jobs | looking around a development database |
Whichever door a job uses, standalone apps share the rules of the close-day script: build the context, call the providers, close the context.
CLI commands with nest-commander are classes marked @Command() that extend CommandRunner, with @Option() methods
that parse their flags; Nest treats every command as an injectable provider. CommandFactory.run(AppModule) builds the
context, runs the command and closes the app when it is done, as the recipe in the Nest documentation describes. Its
serviceErrorHandler option receives the errors that commands throw.
nest-commander is a third-party package, not maintained by the Nest core team, and version 3.21.0 accepts Nest 12.
The REPL starts from a repl.ts beside main.ts that calls await repl(AppModule), run with
npm run start -- --entryFile repl. At its prompt, get() or $ returns a provider, methods() lists its public
methods and debug() prints the modules.
The REPL runs real methods
A call at the REPL prompt is a real call, and a method that writes to the database writes. Point the REPL at a development database; jobs that change production data belong in reviewed scripts or commands.
Checkpoint
Check: Scripts, CLI commands and the REPL
An operations CLI with nest-commander: commands, options and exit codes, run Pro: opens with a Pro, Premium or Ultimate pass
An operations CLI turns the jobs a team runs by hand into named, documented commands. This part builds TiffinTrail’s with three commands, runs each one, and measures how every way of ending sets the exit status a scheduler sees.
In this part:
- Commands are providers: three groups, one module
- What ends a command, and the exit status a scheduler sees
- Making an operations command safe to run by hand
- Testing a command without the command line
1 diagram · 2 runnable examples (JavaScript, TypeScript (commands.ts), TypeScript (ops.ts)) · about 7 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
The REPL on a development database, and scripts that exit without a word Pro: opens with a Pro, Premium or Ultimate pass
The REPL is the fastest way to ask a running app a question, and a script that dies without a message is the slowest failure to explain. This part feeds the REPL a file of commands, then makes a script die three ways and reads what each one leaves behind.
In this part:
- A REPL session, fed four lines
- A script that dies without a word, reproduced three ways
- A checklist for a script a scheduler runs
4 runnable examples (JavaScript, TypeScript) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: a script that reports how it ended
The exercise is the frame of every script in the first section: create the context, run the job, close the context whatever happened, and return the exit status:
Exercise · Easy · JavaScript
Run a standalone job, close it and report how it ended
Write the frame every script built on NestFactory.createApplicationContext() needs, the one the close-day script of the lesson follows: create the context, run the job, close the context whatever happened, and say how it ended, so that a cron job or a deploy step can tell success from failure by the exit code.
runScript(createContext, work) receives two async functions: createContext() returns an application context (an object with an async close()), and work(app) runs the job on it and returns a one-line summary. Return { code, lines }:
- when everything succeeds:
code0 andlinesholding the summary; - when
createContext()throws:code1,linesholdingstart-up failed: <message>, andworkis never called; - when
workthrows:linesholdsjob failed: <message>,codeis 1, and the context is still closed; - when
close()throws: addclose failed: <message>after the other lines, andcodeis 1.
close() is called exactly once whenever a context was created.
Starter code · run-script.mjs
/**
* Runs one standalone job the way a script built on createApplicationContext() should: create the context, run the
* work, always close the context, and report the outcome. Returns { code, lines }: the exit code (0 only when
* everything succeeded) and the lines the script prints.
*/
export async function runScript(createContext, work) {
const app = await createContext();
const summary = await work(app);
return { code: 0, lines: [summary] };
} The sample tests · run-script.test.mjs
import { assert, test } from 'mysmartcopilot:test';
import { runScript } from './run-script.mjs';
/** A fake application context that records whether close() was called, and can fail on close. */
function fakeContext(closeError) {
const app = {
closed: 0,
async close() {
app.closed += 1;
if (closeError) throw new Error(closeError);
},
};
return app;
}
test('a job that succeeds prints its summary, closes the context and exits with 0', async () => {
const app = fakeContext();
const result = await runScript(async () => app, async () => '2026-09-17: closed, 5 lunches sent');
assert.deepEqual(result, { code: 0, lines: ['2026-09-17: closed, 5 lunches sent'] });
assert.equal(app.closed, 1);
});
test('a job that throws still closes the context, and exits with 1', async () => {
const app = fakeContext();
const result = await runScript(async () => app, async () => {
throw new Error('--at must be an ISO time');
});
assert.deepEqual(result, { code: 1, lines: ['job failed: --at must be an ISO time'] });
assert.equal(app.closed, 1);
});
test('a start-up that fails has no context to close, and exits with 1', async () => {
let worked = false;
const result = await runScript(
async () => {
throw new Error('menu service unreachable');
},
async () => {
worked = true;
return 'never';
},
);
assert.deepEqual(result, { code: 1, lines: ['start-up failed: menu service unreachable'] });
assert.equal(worked, false);
});
test('a close that fails after a good job is reported, and the exit code is 1', async () => {
const app = fakeContext('pool did not close');
const result = await runScript(async () => app, async () => 'done');
assert.deepEqual(result, { code: 1, lines: ['done', 'close failed: pool did not close'] });
});
test('a failed job and a failed close are both reported', async () => {
const app = fakeContext('pool did not close');
const result = await runScript(async () => app, async () => {
throw new Error('kitchen pune-12 not found');
});
assert.deepEqual(result, { code: 1, lines: ['job failed: kitchen pune-12 not found', 'close failed: pool did not close'] });
});
test('the work receives the context that was created', async () => {
const app = fakeContext();
let received;
await runScript(async () => app, async (ctx) => {
received = ctx;
return 'ok';
});
assert.equal(received, app);
}); A hint
Use three separate try blocks, one per step, so that a failure in one does not skip the next: the first returns early when there is no context, the second records the job's failure and sets code to 1, and the third, which always runs, closes the context. A single try … finally around the job and close() works too, as long as an error in close() cannot hide the job's own result.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then the self-check on application contexts and on the three ways to run a job, which asks first about the idea you are least sure of:
Practice that adapts to you
Practise this lesson
7 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
What is the difference between NestFactory.create() and NestFactory.createApplicationContext(), and when would you use the second?
Both read the root module and every module it imports, build the same providers and run the start-up lifecycle hooks. NestFactory.create() also creates an HTTP server through the adapter, registers the routes of the controllers and puts guards, pipes, interceptors and filters in front of them. createApplicationContext() stops before that: no server, no port, no routes, and nothing of the request pipeline. Use it for work that needs the app's services without serving requests: a script run by a scheduler, such as this lesson's close-day job at the 10:30 IST cut-off, a queue worker, or the base of a CLI. Take providers with app.get() or app.select(Module).get(Token, { strict: true }), and end with await app.close(), which runs the shutdown hooks and lets the process exit.
-
Mid to senior
A nightly billing command built with nest-commander reports success to the scheduler every night, yet some mornings no invoices exist, and the logs of those nights show an error message. What is the likely cause, and how do you make failures visible to the scheduler and to people?
-
Mid to senior
A data-fix script built on createApplicationContext() ends with exit status 1 in CI and prints nothing at all; on a laptop it works. How do you find out what failed, and what do you change so that it can never fail silently again?
-
Mid to staff
During a 20-minute outage, one kitchen's display missed 40 orders. The on-call engineer must send them again now, and the same may be needed after the next outage. Would you use the Nest REPL, a one-off script or a CLI command? Argue for one, and say what you would build in to make it safe.
Key takeaways
NestFactory.createApplicationContext()builds every module and provider and runs their lifecycle hooks, with no HTTP server, so a script reuses the API’s services.- A script ends with
await app.close(), which runs the shutdown hooks and lets the process exit with the status the script set. - Guards, pipes and interceptors act on requests, so they do not apply to methods a script calls directly.
- A script fits one job, CLI commands with nest-commander fit a toolbox of named jobs with options, and the REPL fits questions asked by hand against a development app.
Terms in this lesson
- application context
- What NestFactory.createApplicationContext() returns: an app's modules and providers, built and with their lifecycle hooks run, but no HTTP server, routes or request pipeline. Scripts, workers and CLIs run on it.
- REPL
- A read-eval-print loop: a prompt that runs each line typed and prints the result. Nest's repl() from @nestjs/core opens one on an app's modules, with helpers such as get() and debug().
References
- NestJS documentation: Standalone applications (NestJS)
- NestJS documentation: Nest Commander (recipe) (NestJS)
- NestJS documentation: REPL (recipe) (NestJS)
- nest-commander 3.21.0 on npm (peer dependencies @nestjs/core and @nestjs/common ^8 to ^12) (npm)
- nest-commander source: command-runner.service.ts (how a command runs and how its errors are handled) (nest-commander (GitHub))
- @nestjs/core source: errors/exceptions-zone.ts (what a start-up error does) (NestJS (GitHub))
- Kubernetes documentation: Jobs (Pod backoff failure policy) (The Kubernetes Authors)
- MDN: Intl.NumberFormat (MDN)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress