A complete backend roadmap built on a real codebase. Start with why modules exist, learn how the injector thinks, walk a request through every stage, then wire PostgreSQL, Redis, queues, sockets, auth, storage and tests until you can ship the whole thing.
By Shree Kumar Sharma, with Claude Design, for Backend Engineering. Progress is saved in this browser only.
A catalog of every keyword in this roadmap. Each entry says what the word means in plain language, what it means technically, and what it is like in everyday life, then links to the module that teaches it.
In detail
Use this module as a reference rather than a lesson. Filter by kind to see only decorators or only concepts, or type a word to find it. Words such as @Injectable, Guard or Pipe are highlighted in every code example on this page and link back to the module that explains them, so you can always jump from code to meaning.
Module
Concept
In plain words
A folder of code that belongs together, with a clear front door.
Technically
A class decorated with @Module that declares imports, providers, controllers and exports, forming one node in the dependency graph.
Think of it as
A department in a company: it has its own staff and only shares certain people with other departments.
NestJS is a framework that sits on top of Express or Fastify and gives a Node.js server the structure that large teams need: modules, dependency injection, and a fixed request pipeline.
In detail
Express hands you a router and leaves the rest to you, which works until ten people are editing the same app. NestJS borrows the ideas Angular proved on the frontend and applies them to the server. Every feature lives in a module, every class gets its dependencies from an injector instead of building them, and every request walks through the same ordered chain of middleware, guards, interceptors and pipes. The result is code you can test in isolation and reason about when it grows.
hello.controller.tsTypeScript
@Controller("hello")exportclassHelloController {@Get()greet(): string {return"Hello from NestJS"; }}
// main.ts and app.module.ts in one place, the smallest NestJS app that runsimport"reflect-metadata";import { Controller, Get, Module } from"@nestjs/common";import { NestFactory } from"@nestjs/core";@Controller("hello")exportclassHelloController {@Get()greet(): string {return"Hello from NestJS"; }}@Module({ controllers: [HelloController],})exportclassAppModule {}asyncfunctionbootstrap(): Promise<void> {const app = awaitNestFactory.create(AppModule);await app.listen(3000);}voidbootstrap();
Why it matters A controller, a module and a factory call are the whole framework in miniature. Everything later in this roadmap adds to one of these three.
The Nest CLI creates a project, generates files with the right names, and builds with tsc or SWC. Learn the three config files it writes and you understand the whole toolchain.
In detail
Run nest new once and you get main.ts, an AppModule, a test folder, nest-cli.json, tsconfig.json and tsconfig.build.json. nest-cli.json tells the CLI where source lives and which compiler to use. tsconfig must keep emitDecoratorMetadata and experimentalDecorators on, because dependency injection reads constructor types from that emitted metadata. Switching the builder to SWC makes rebuilds roughly an order of magnitude faster on large apps.
3 examples
terminalShell
npm i -g @nestjs/clinest new lexicon-api --package-manager pnpm --strictcd lexicon-api && pnpm start:dev
# install the CLI oncenpm i -g @nestjs/cli# create a strict TypeScript project with pnpmnest new lexicon-api --package-manager pnpm --strictcd lexicon-api# run with file watchingpnpm start:dev# production build (tsc or swc depending on nest-cli.json)pnpm buildnode dist/main.js# print the CLI and framework versionsnest info
Why it matters --strict turns on strictNullChecks and noImplicitAny from day one, which is far cheaper than retrofitting them later.
main.ts creates the application from the root module, then configures everything that applies globally: prefix, versioning, CORS, pipes, shutdown hooks and documentation.
In detail
NestFactory.create walks the module graph, instantiates every provider in dependency order, and returns an application object. Anything configured on that object before listen() applies to every route. Keep main.ts short by moving each concern into a small setup function, and enable shutdown hooks so database pools and queues close cleanly when the process is told to stop.
A NestJS feature is a folder of small files, each with one job and a suffix that names that job. Read the suffix and you know what the file exports before opening it.
In detail
The suffix convention is not enforced by the compiler, but the CLI, editor icon themes and every experienced reader rely on it. Keep one class per file, put shared shapes in contracts or interfaces files, and keep tests beside the file they cover. The table below lists each suffix with what it holds and when to create one.
A module is a boundary. It owns a set of providers, decides which of them other modules may use, and declares which modules it depends on.
In detail
Without modules, every class could reach every other class and the dependency graph turns into a web. A module makes that graph explicit: imports says what this feature needs, providers says what it builds, exports says what it shares, and controllers says which routes it serves. Nest instantiates each module once and caches it, so a provider exported from SharedModule is the same instance wherever it is imported. That boundary is also what lets you load a feature lazily, replace it in tests, or lift it into its own service later.
Group code by feature first and by kind second. A feature module keeps its controller, service, gateway, schema and tests together, while cross cutting code lives in a small number of top level folders.
In detail
The layout below comes from a real production codebase. src/modules holds features, src/routing holds the controller decorators that define URL planes, src/redis and src/database hold infrastructure modules, and src/common holds filters, pipes and interceptors that any feature may use. Inside a feature, every file carries the feature name as a prefix so a search for bootstrap. lists the whole feature. Large features split into sub folders such as auth, services, schema and public once a single folder passes about fifteen files.
Project root
src
commonfilters, pipes, interceptors, decorators
configenv schema and config module
databasedata sources and migrations
graphify-outgenerated graph output
middlewaresignature, request id
modulesfeature modules
posthoganalytics module
redisclient, service, cache keys
routingdecorators and plane constants
socketsadapters and socket guards
typesbranded and global types
utilspure helpers
main.ts
app.module.ts
One feature, flat and prefixed
modules/bootstrap
bootstrap.constants.ts83 B
bootstrap.contracts.ts16.2 KB
bootstrap.controller.ts24.3 KB
bootstrap.events.test.ts669 B
bootstrap.events.ts305 B
bootstrap.gateway.ts10.0 KB
bootstrap.interfaces.ts4.4 KB
bootstrap.module.ts4.5 KB
bootstrap.schema.test.ts2.4 KB
bootstrap.schema.ts3.4 KB
bootstrap.service.ts26.3 KB
index.tsbarrel
The service and controller are the large files; constants and events stay tiny and rarely change.
An index.ts at the root of a module re-exports its public surface, so the rest of the app imports from one path and never reaches into private files.
In detail
A barrel turns a folder into a package with a front door. Other modules import BootstrapModule and its contracts from @modules/bootstrap and nothing else, which means you can rename or split internal files freely. Keep barrels shallow: export the module, its public service, its contracts and its tokens. Do not re-export everything with export star from every file, because deep barrels hide circular imports and slow down test startup. When two barrels import each other, Nest receives undefined at decoration time and reports a circular dependency that is really an import cycle.
nest generate creates modules, controllers, services, guards and whole CRUD resources, and wires each one into the nearest module for you.
In detail
Generators save typing, but their real value is consistency: every file gets the same suffix, the same spec beside it, and the right registration. nest g resource asks whether you want REST, GraphQL or WebSockets and produces a module, controller, service, DTOs and entity in one go. Use --flat to skip the extra folder, --no-spec when a test makes no sense, and --dry-run to preview what would change.
terminalShell
nest g resource modules/bootstrap --no-specnest g service modules/bootstrap --flat
# whole CRUD feature: module, controller, service, dto, entitynest g resource modules/bootstrap# individual building blocksnest g module modules/quiznest g controller modules/quiz --flatnest g service modules/quiz --flatnest g gateway modules/bootstrap/bootstrap --flatnest g guard common/guards/roles --flatnest g pipe common/pipes/zod-validation --flatnest g interceptor common/interceptors/logging --flatnest g middleware middleware/signature-secret --flatnest g filter common/filters/http-exception --flatnest g decorator routing/decorators/api-controller --flat# preview without writing filesnest g resource modules/lessons --dry-run
Why it matters --flat writes into the given folder instead of creating a new one, which keeps feature folders from nesting twice.
02
Phase 02, modules 09 to 19
The injector's mind
Dependency injection
Providers, tokens, scopes and the resolution rules that decide what gets built and when.
A decorator is a function that runs when a class is defined and attaches metadata to it. NestJS reads that metadata at startup to learn routes, dependencies and rules.
In detail
TypeScript supports class, method, property and parameter decorators. @Controller stores a path, @Get stores a method and path, and @Injectable marks a class for the injector. With emitDecoratorMetadata on, the compiler also writes design:paramtypes, the list of constructor argument types, which is exactly how Nest knows that BootstrapService needs a Repository. SetMetadata and Reflector let you add and read your own keys, and applyDecorators composes several decorators into one.
import"reflect-metadata";import { Injectable } from"@nestjs/common";classRedisService {}classWordRepository {}@Injectable()classBootstrapService {constructor(privatereadonly words: WordRepository,privatereadonly redis: RedisService, ) {}}// The compiler emitted this because a decorator sits on the classconst types = Reflect.getMetadata("design:paramtypes", BootstrapService);console.log(types.map((t: Function) => t.name)); // [ "WordRepository", "RedisService" ]// Interfaces and type aliases emit as Object, which is why they need tokens
Why it matters This is the mechanism behind constructor injection. If a type erases to Object, Nest cannot know what to inject.
A provider is anything Nest can create and hand to another class: services, repositories, factories, helpers and plain values. @Injectable marks a class as one.
In detail
Listing a class in providers registers it under its own class as the token. When another class asks for that type in its constructor, the injector looks up the token in the current module, then in imported modules' exports, builds the instance if needed, and caches it. By default each provider is a singleton for the whole app. @Injectable is only required when the class itself has constructor dependencies, but adding it everywhere keeps intent clear.
Behind every injection is a token. A class can be its own token, but strings, symbols and InjectionToken values let you inject values, factories, aliases and implementations of interfaces.
In detail
useClass maps a token to a class, which is how you bind an interface to an implementation. useValue injects a constant or a mock. useFactory runs a function with injected arguments and can be async, which is the right place to open a connection. useExisting creates an alias so two tokens resolve to one instance. Prefer symbols or exported constants over bare strings so a typo fails at compile time.
Providers are singletons by default. REQUEST scope builds a fresh instance per incoming request, and TRANSIENT builds a fresh instance for every consumer.
In detail
Scope bubbles up: if a singleton depends on a request scoped provider, the singleton becomes request scoped too, and every request pays the construction cost. Reach for REQUEST scope only when a provider truly needs per request state, such as a tenant id, and prefer AsyncLocalStorage through nestjs-cls for request context in hot paths. TRANSIENT suits stateful helpers such as a logger that stores its own context name.
Make a class an injectable provider when it has dependencies, holds a connection or state, or needs to be replaced in tests. Keep pure functions as plain imports.
In detail
A formatter that turns a date into a string needs nothing and changes nothing, so a plain exported function is simpler and faster to test. A class that talks to Redis, reads config, or sends email must be a provider, because the injector controls its lifetime and tests swap it for a fake. A useful rule: if you would ever write jest.mock for it, it should be injectable instead. Static utility classes that read process.env directly are a smell, since they bypass validated config.
import { Injectable } from"@nestjs/common";import { ConfigService } from"@nestjs/config";import { RedisService } from"@redis/redis.service";// * // Keep as a utility: no dependencies, no state, deterministic //exportfunctionmaskEmail(email: string): string {const [user, domain] = email.split("@");return`${user.slice(0, 2)}***@${domain}`;}// * // Make injectable: reads config, holds a connection, needs a fake in tests //@Injectable()exportclassOtpService {constructor(privatereadonly redis: RedisService,privatereadonly config: ConfigService, ) {}ttl(): number {returnthis.config.get<number>("OTP_TTL_SECONDS", 300); }}// * // Checklist //// Has constructor dependencies? -> injectable// Holds a connection, pool or cache? -> injectable// Needs to be replaced in tests? -> injectable// Reads configuration? -> injectable// Pure input to output transformation? -> plain function in utils
Why it matters Plain functions are imported, providers are injected. Mixing the two roles is the most common cause of hard to test code.
To use a provider from another feature, import the module that owns it and make sure that module exports the provider. Never list another module's service in your own providers array.
In detail
Copying RedisService into two providers arrays creates two instances with two connection pools, which is a quiet bug. The owning module should build it once and export it. A module can also re-export an imported module, so CoreModule can import and export RedisModule and ConfigModule together and features import CoreModule alone. Export only what consumers need, because every export is a promise you have to keep.
A dynamic module is a module returned from a static method such as forRoot, register or registerAsync, so the caller can pass options. ConfigurableModuleBuilder writes most of that code for you.
In detail
forRoot conventionally configures something once for the whole app, register configures it per importing module, and the Async variants accept a factory that can inject ConfigService. AuthJwtModule.registerAsync() takes no arguments because it reads its secrets from config inside its own factory, which keeps every importing feature identical.
Factories decide which implementation to build at runtime, and builders assemble complex objects step by step. Both fit naturally into NestJS providers.
In detail
A factory provider can pick a mail transport from config: SMTP in production, a JSON transport in tests. A builder is a plain class that accumulates options and returns an immutable result, which is useful for query objects, email messages and API responses. Inject factories, keep builders as plain classes created per use, since they hold per call state.
A circular dependency happens when A needs B and B needs A, either between modules or between providers. forwardRef delays resolution so both can be built.
In detail
Between modules, wrap the import in forwardRef(() => QuizModule) on both sides. Between providers, use @Inject(forwardRef(() => QuizService)) on both constructors. forwardRef treats the symptom: it usually means two features share logic that belongs in a third module, or one side should communicate through events instead. Also check for barrel import cycles, which produce the same error even when the DI graph is fine.
Nest can't resolve dependencies of X is the error every NestJS developer meets. The message tells you which argument failed and in which module, which is enough to fix it every time.
In detail
The question mark in the printed list marks the argument that failed. Check four things in order: is the provider listed in this module's providers, or exported by a module listed in imports; is the import a class, not a type that erased to Object; is there an import cycle that made the class undefined at decoration time; and is the token you inject the exact token that was provided. A provider that appears in two providers arrays is not an error but creates two instances.
error.logTypeScript
Nest can't resolve dependencies of the BootstrapService (WordRepository, ?).Please make sure that the argument RedisService at index [1] is availablein the BootstrapModule context.
// * // The error //// Nest can't resolve dependencies of the BootstrapService (WordRepository, ?).// Please make sure that the argument RedisService at index [1] is available in the BootstrapModule context.// * // Fix 1: the owning module is not imported //@Module({ imports: [RedisModule], // add this providers: [BootstrapService],})exportclassBootstrapModule {}// * // Fix 2: the owning module does not export it //@Module({ providers: [RedisService], exports: [RedisService], // add this})exportclassRedisModule {}// * // Fix 3: the type erased at runtime //// constructor(private readonly repo: IBootstrapRepository) {} // interface, fails// constructor(@Inject(BOOTSTRAP_REPOSITORY) private readonly repo: IBootstrapRepository) {}// * // Fix 4: an import cycle produced undefined //// "argument dependency at index [0]" or "undefined" in the list -> check barrel imports// * // Inspect the graph //// const app = await NestFactory.create(AppModule, { snapshot: true });// npx @nestjs/devtools-integration
Why it matters Index [1] means the second constructor argument. Count from zero and you know exactly which parameter to fix.
@Global makes a module's exports available everywhere without importing it. ModuleRef resolves providers at runtime, and lifecycle hooks run code when modules start and stop.
In detail
Use @Global sparingly, for true infrastructure such as config, logging and Redis, because global exports hide where dependencies come from. ModuleRef.get fetches a singleton by token, resolve builds scoped providers, and create instantiates a class that was never registered. Lifecycle hooks run in a fixed order: onModuleInit, onApplicationBootstrap, then on shutdown onModuleDestroy, beforeApplicationShutdown and onApplicationShutdown.
A controller maps HTTP verbs and paths to methods. It reads input, calls a service, and returns a value that Nest serializes to JSON.
In detail
Keep controllers thin: no database calls, no business rules, just translation between HTTP and your service layer. Route decorators such as @Get, @Post and @Patch take a path, and parameter decorators such as @Param, @Query, @Body and @Headers pull parts of the request in. Return plain values or promises and let Nest set status codes; use @HttpCode or @Header when you need to change them. Reach for @Res only when you stream or redirect, because it switches off the standard response handling.
Services hold business rules. They coordinate repositories, caches, queues and other services, and they never know about HTTP or sockets.
In detail
If a method needs a Request object, it belongs in a controller or guard. A service throws domain errors or Nest's HTTP exceptions, returns typed results, and can be called from a controller, a gateway, a cron job or a queue processor alike. Split a service when it passes about four hundred lines or starts mixing concerns: BootstrapService coordinates, WordQueryService reads, WordWriteService writes.
Utilities are pure functions with no dependencies: string transforms, date math, key builders, type guards. They live in src/utils and are imported, not injected.
In detail
Group utilities by subject in small files and export them through src/utils/index.ts. Each function should be deterministic and easy to unit test without a Nest testing module. When a helper starts needing config or a client, promote it to a provider in the right module rather than passing process.env around.
Composed controller decorators replace @Controller with intent: @ApiController, @AuthController, @DashboardController. Each one sets a URL plane, a version and metadata that guards and docs read later.
In detail
applyDecorators bundles SetMetadata, Controller, ApiTags and any guard into one call. The route type metadata lets a single global guard decide policy by plane: internal routes need a service key, dashboard routes need a session, API routes need a JWT. The routing folder keeps the enums, metadata keys and option interfaces next to the decorators, and an index.ts exposes them as one import.
import { RouteVersion } from"./constants/route-versions.enum";// * // Option contracts used by every routing decorator //exportinterfaceRoutedControllerOptions {/** Path segment after the plane prefix, without a leading slash. */ path: string;/** OpenAPI tag. Defaults to the plane name. */ tag?: string;}exportinterfaceApiControllerOptionsextendsRoutedControllerOptions { version?: RouteVersion;}// * // Public surface //export * from"./constants/route-types.enum";export * from"./constants/route-versions.enum";export * from"./constants/route-metadata.keys";export * from"./decorators";
Why it matters Option interfaces live in the barrel so decorators and their callers share one definition.
URI versioning keeps v1 and v2 of a route alive side by side. RouterModule mounts whole modules under a path so the URL tree mirrors the module tree.
In detail
Enable versioning once in main.ts, then mark controllers or single routes with version. A global prefix such as api can exclude health checks and dashboard pages. RouterModule.register maps a module to a path and its children to sub paths, which is useful when one module owns a whole admin area. The custom decorators from the previous module are an alternative: they bake the plane and version into the controller path directly.
Every request passes through the same stages in the same order: middleware, guards, interceptors before, pipes, the handler, interceptors after, and exception filters if anything throws.
In detail
Within each stage, global runs first, then controller level, then route level. Interceptors wrap the handler, so their after logic runs in reverse order. Exception filters run from the most specific outward. Knowing this order answers most questions about where code belongs: authentication in a guard, input shape in a pipe, response shape in an interceptor, and raw request work such as signature checks in middleware.
Middleware runs before routing decisions are final and sees the raw request and response. It is the place for signature checks, request ids, raw body capture and logging.
In detail
Class middleware implements NestMiddleware and can inject providers. Functional middleware is a plain function and is slightly faster when it needs nothing. Middleware does not know which handler will run and cannot read decorator metadata, which is why authorization belongs in guards. Register it in a module's configure method with MiddlewareConsumer, or globally with app.use for third party middleware such as helmet.
MiddlewareConsumer applies middleware to whole controllers or path patterns, and exclude carves out paths and specific methods. That is how a signature check covers a controller while login stays open.
In detail
forRoutes accepts controller classes, strings with wildcards, or objects with path and method. exclude takes the same shapes and is matched before forRoutes. Paths are written without the global prefix. When you exclude an object with a method, only that verb on that path is skipped, so PATCH bootstrap/words/:id can stay open to an admin tool while GET on the same path is still checked. Chain several apply calls to give different controllers different middleware.
A guard answers one question before the handler runs: may this request continue. It has the execution context, so it can read route metadata, the user and the handler.
In detail
Return true to continue, false to send 403, or throw your own exception. Register guards globally with APP_GUARD so they can inject providers, then opt routes out with metadata such as @Public. Order matters when several guards apply: authentication first, then roles, then feature flags. Guards also run for WebSocket and microservice handlers, where you switch on context.getType().
Pipes transform and validate arguments before the handler sees them. A pipe either returns a value, possibly converted, or throws to reject the request.
In detail
Built in pipes cover common cases: ParseIntPipe, ParseUUIDPipe, ParseBoolPipe, ParseEnumPipe, ParseArrayPipe, DefaultValuePipe and ParseFilePipe. Apply a pipe to one parameter, a method, a controller, or globally. A custom pipe implements PipeTransform and receives the value plus metadata describing where it came from.
A Zod schema validates the body and produces a typed value in one step. A small ZodValidationPipe connects it to NestJS and turns issues into a clean 400 response.
In detail
Define the schema in bootstrap.schema.ts and infer the DTO type from it in bootstrap.contracts.ts, so validation and types never drift. The pipe uses safeParse and maps issues to field paths. Wrap it in a @ZodBody decorator for readable controllers. Zod also handles coercion for query strings, defaults, transforms and refinements across fields.
Headers carry client version, signatures, idempotency keys and locale. Validate them with the same Zod approach through a custom parameter decorator.
In detail
@Headers() returns every header as lowercase keys, which Zod can parse like any object. Use passthrough so unrelated headers do not fail validation, and reject only what you declare. Typical checks: an x-client-version that matches semver, an idempotency-key that is a UUID on POST, and accept-language limited to supported locales.
An interceptor wraps the handler. It can run code before, transform the result after, map errors, add timeouts, or skip the handler entirely by returning a cached value.
In detail
Interceptors work with RxJS: next.handle() returns an Observable of the handler's result, and operators such as map, tap, timeout and catchError reshape it. Because they see the execution context and the result, they suit response envelopes, serialization, timing and caching. Register globally with APP_INTERCEPTOR to allow injection.
Middleware sees the raw request before Nest knows the handler. Interceptors run after routing, know the handler and its metadata, and can change the result.
In detail
Choose middleware for work on the raw stream: body signatures, request ids, cookies, CORS and anything third party that speaks Express. Choose an interceptor when you need the handler, its decorators, or its return value: caching, envelopes, serialization and timing per route. Middleware cannot see exceptions thrown by the handler as values; interceptors can map them. Only interceptors, guards and pipes run for WebSocket gateways.
Middleware compared with interceptors
Question
Middleware
Interceptor
Runs
Before routing is final
After a handler is selected
Knows the handler and its metadata
No
Yes, through ExecutionContext and Reflector
Sees the return value
No
Yes, through the Observable from next.handle()
Can map thrown errors
Only by wrapping next() itself
Yes, with catchError
Works for WebSockets and microservices
No, HTTP only
Yes
Dependency injection
Class middleware only
Yes
Typical jobs
Request ids, signatures, raw body, CORS, helmet, cookies
Exception filters catch thrown errors and write the response. A global filter gives every error the same JSON shape, with request ids and no stack traces in production.
In detail
HttpException and its subclasses carry a status and a body. Unknown errors become 500. @Catch() with no argument catches everything; @Catch(QueryFailedError) handles database errors, for example turning a unique violation into 409. Use HttpAdapterHost so the filter works on Express and Fastify alike.
createParamDecorator builds decorators like @CurrentUser or @ClientIp that extract a value from the request. Handlers stay clean and testable.
In detail
The factory receives an optional argument and the execution context. Return any value, and combine with pipes by passing them after the argument. A decorator that works for both HTTP and sockets switches on context.getType(). Keep them tiny: extraction only, no database calls.
FileInterceptor, FilesInterceptor and FileFieldsInterceptor parse multipart form data with Multer. ParseFilePipe validates size and type before your handler runs.
In detail
Use memory storage when the next step is S3, so files never touch local disk. Set limits on file size and count in the interceptor options as a first line of defence, then validate again with ParseFilePipeBuilder. Check the magic bytes, not only the mimetype header, for anything security sensitive, because clients choose the header.
@nestjs/throttler limits how many requests a client may make in a window. Back it with Redis so limits hold across every instance of the app.
In detail
Define named throttlers such as short and long, register ThrottlerGuard globally, and override per route with @Throttle or skip with @SkipThrottle. The default tracker is the IP; override getTracker to limit by user id or API key. Clients that exceed a limit get 429 with a Retry-After header. OTP and login routes deserve their own stricter limits.
TypeScript interfaces describe shapes for the compiler and disappear in the emitted JavaScript. In NestJS that means an interface can type a dependency but can never be its injection token.
In detail
Use interfaces in bootstrap.interfaces.ts for ports such as repositories and gateways, for service method results, and for options objects. Pair each injectable interface with a symbol token in bootstrap.constants.ts. Interfaces cannot validate input either, since nothing exists at runtime to check against, which is why request bodies need a schema.
A DTO describes data that crosses a boundary: a request body, a query, a response or a socket payload. The contracts file collects every DTO type of a feature in one place.
In detail
In a Zod based codebase, contracts are inferred from schemas with z.infer, so bootstrap.contracts.ts imports schemas and exports types. Response contracts that have no schema are written as plain types. Other features, the frontend and the socket client import from contracts and nowhere else, which makes it the file to review whenever an API changes.
A schema is a runtime value that validates data. A DTO is a compile time type that describes it. You need both, and the cleanest setup derives the DTO from the schema.
In detail
The class-validator style puts both in one class: decorators validate and the class is the type. The Zod style keeps the schema as a value and infers the type, which gives richer validation, transforms and no reflection. Either works; mixing them in one feature does not. Pick one per codebase and document it in the module structure.
Interfaces describe how parts of your code talk to each other. DTOs describe what outsiders send and receive. Keep them apart so internal changes do not break clients.
In detail
An entity has database columns, relations and timestamps. A WordRecord interface is what services pass around. A response DTO is the public shape, often fewer fields and stable names. Mapping between them in one function per direction is a small cost that buys freedom to rename columns, split tables or change shards without touching the API.
Design types first and let them drive the code: schemas infer DTOs, event maps type emitters, branded ids stop mixups, and discriminated unions make impossible states impossible.
In detail
Branded types such as UserId and RoomId are strings at runtime but distinct to the compiler, so passing a room id where a user id is expected fails to build. Discriminated unions model results and socket messages with a kind field, and an exhaustive switch with assertNever turns every new case into a compile error until it is handled. satisfies checks an object against a type without widening it.
types/brand.tsTypeScript
exporttypeBrand<T, Bextendsstring> = T & { readonly __brand: B };exporttypeUserId = Brand<string, "UserId">;
bootstrap.constants.ts holds tokens and fixed values, and bootstrap.events.ts holds event names with their payload types. Strings typed once are strings never mistyped.
In detail
An events file exports a const object of names and an interface mapping each name to its payload. Emitters and listeners import both, so a renamed event or changed payload fails to compile everywhere it is used. Small files like these change rarely, which is why they barely move in the listing while services grow. Test them lightly: a test that asserts names are unique and namespaced catches copy paste mistakes.
ConfigModule loads .env files, validates them at startup, and exposes values through an injectable ConfigService. A missing or malformed variable should stop the app before it serves a request.
In detail
Validate with a Zod schema in the validate option so types are coerced and defaults applied. Group related values with registerAs namespaces and inject them as typed objects with ConfigType. Mark ConfigModule global and cache it. Never read process.env in services; inject config so tests can supply values.
TypeOrmModule.forRootAsync connects to PostgreSQL with settings from ConfigService. Feature modules then register the entities they use with forFeature.
In detail
Keep synchronize off outside throwaway local databases and use migrations instead. autoLoadEntities picks up every entity registered with forFeature, so the root config does not list them. Tune the pool with extra.max, set statement timeouts, and use the snake case naming strategy so TypeScript properties map to idiomatic PostgreSQL columns.
An entity class maps to a table. Registering it with TypeOrmModule.forFeature inside a module makes its Repository injectable there and nowhere else.
In detail
forFeature takes an array of entities and an optional data source name. Without a name it uses the default connection. The module that registers an entity owns its repository; other modules use the owning service rather than registering the entity again. Indexes, unique constraints and relations are declared on the entity and created by migrations.
A second named data source connects to another database. Shard entities share columns but map to different tables, and forFeature registers them under that data source name.
In detail
Name the connection in forRootAsync, then pass the same name to forFeature and to @InjectRepository. A base class holds the columns and each shard subclass only sets the table name. A small resolver picks the repository from the shard number, so services never hard code Words_2. Keep transactions inside one data source; spanning two needs an outbox or saga.
Repository methods cover simple reads and writes. The query builder handles joins, aggregates, locking and anything the find options cannot express.
In detail
Wrap repeated queries in a custom repository class that injects the TypeORM repository, so services read like intent. Select only the columns you need, paginate with keyset for large tables, and add explicit indexes for every filter you ship. getRawMany returns aggregates without entity hydration, which is faster for stats.
A transaction groups writes so they all commit or all roll back. In TypeORM use DataSource.transaction for most cases and a QueryRunner when you need manual control.
In detail
Inside the callback, use the provided EntityManager for every query; anything using an injected repository runs outside the transaction. Lock rows you read and then update with pessimistic_write to avoid lost updates. Emit events and enqueue jobs after commit, not inside, or a rollback can leave a job referencing data that never existed.
Migrations are versioned scripts that change the database schema. Generate them from entity changes, review the SQL, commit them, and run them on deploy.
In detail
A separate data-source.ts file lets the TypeORM CLI find entities and migrations outside Nest. Generate with migration:generate, which diffs entities against the live schema. Read every generated file; renames often appear as drop and add, which loses data. Make down methods real so a bad deploy can be reversed.
A global RedisModule creates one ioredis client from config and exposes a small typed RedisService. Every feature borrows that one connection.
In detail
Wrap the raw client so features use getJson, setJson and del instead of remembering to stringify. Prefix keys per app and build them in one CacheKeys helper, so you can find and expire them later. Close the client on shutdown. For BullMQ and pub sub, create separate connections, since a subscribed client cannot run normal commands.
redis.service.tsTypeScript
asyncgetJson<T>(key: string): Promise<T | null> {const raw = awaitthis.client.get(key);return raw ? (JSON.parse(raw) asT) : null;}
CacheModule with a Redis store gives you CacheInterceptor for whole responses and an injectable cache manager for fine grained cache aside logic.
In detail
Response caching suits public GET routes with the same answer for everyone. For anything user specific or partial, use cache aside in the service: read the key, fall back to the database, write with a TTL, and delete the key on writes. Add jitter to TTLs so many keys do not expire at once, and use a short lock to stop a stampede when a hot key expires.
@nestjs/event-emitter lets one feature announce that something happened and others react, without importing each other. It is the first tool against circular dependencies.
In detail
Emit after state changes are committed. Listeners decorated with @OnEvent run in the same process; set async: true and wrap work in try and catch, because a throwing async listener would otherwise surface as an unhandled rejection. Wildcards such as bootstrap.* subscribe to a family of events. For work that must survive a restart, enqueue a job instead.
@nestjs/schedule runs methods on cron expressions, intervals or timeouts. In a cluster, guard each job with a Redis lock so only one instance runs it.
In detail
Name every job so SchedulerRegistry can pause or inspect it. Set a timeZone for anything tied to a calendar day. Keep cron handlers short: they should enqueue work, not do it, so a long task does not overlap its own next run. A SET NX lock with a TTL slightly shorter than the interval is enough for most leader election needs.
BullMQ stores jobs in Redis and processes them in workers with retries, backoff, delays, priorities and concurrency. @nestjs/bullmq wires queues and processors into modules.
In detail
Register the connection once with forRootAsync and each queue with registerQueue. A processor extends WorkerHost and switches on job.name. Make processors idempotent, since a job can run twice after a crash. Give jobs stable ids when duplicates must be ignored, cap retained completed jobs, and listen to failed events for alerting. Run workers in a separate process when jobs are CPU heavy.
RabbitMQ routes messages between services through exchanges and queues. Use it when other services, not just this app, need to produce or consume events.
In detail
BullMQ is a job queue for one app; RabbitMQ is a broker for many. With @golevelup/nestjs-rabbitmq you publish to a topic exchange and subscribe with @RabbitSubscribe on a queue bound by routing key. Acknowledge after the work succeeds, send poison messages to a dead letter exchange, and keep consumers idempotent. The built in Nest microservice transport also supports RabbitMQ for request reply patterns.
Send email from a queue worker, never from the request. A MailService renders a template, and a processor delivers it with retries.
In detail
The controller or OTP service adds a mail job and returns immediately. The processor renders the template with EJS or Handlebars and sends through the injected transport from the factory module. Retries with exponential backoff absorb SMTP hiccups, and a stable job id prevents duplicate sends when a client retries.
Why it matters The limiter keeps sending under 20 messages per second, which most SMTP providers require.
templates/otp.ejsHTML / EJS
<p>Your code is <strong><%= code %></strong></p>
<!doctype html><htmllang="en"><bodystyle="font-family: system-ui, sans-serif; color: #1d1418"><h1style="font-size: 20px">Your sign in code</h1><p>Use this code within <%= minutes %> minutes:</p><pstyle="font-size: 28px; letter-spacing: 6px"><strong><%= code %></strong></p><p>If you did not ask for this, you can ignore this email.</p></body></html>
Why it matters <%= escapes output, so a name or code can never inject HTML into the message.
A one time password lives in Redis with a TTL, a hashed value and an attempt counter. Redis handles expiry and atomic counters, so the service stays small.
In detail
Store a hash of the code, never the code. Limit sends per target with a cooldown key, limit verify attempts with INCR, and delete the code after a successful check so it cannot be reused. Compare in constant time. Use a dedicated throttle on the send route as well, since every send costs money and an inbox.
A use case often touches several services: create a player, award a starter pack, send a welcome email, and track the signup. An orchestrating service calls them in order and handles partial failure.
In detail
Keep each service focused and let one application service coordinate. Run independent steps in parallel with Promise.allSettled, run dependent ones in sequence, and push side effects that can wait into queues. Decide per step whether failure aborts the whole use case or is logged and retried later. This keeps the controller to one line and every rule in one readable place.
onboarding.service.tsTypeScript
const player = awaitthis.players.create(input);awaitPromise.allSettled([this.packs.grantStarter(player.id), this.mail.enqueue(welcome), this.analytics.capture(...)]);
A gateway is a provider decorated with @WebSocketGateway. @SubscribeMessage handlers receive client events, and the server instance broadcasts to rooms.
In detail
Type the server with your ServerToClientEvents and ClientToServerEvents maps from the contracts file, so emits are checked at compile time. Return a value from a handler to send an acknowledgement. Use namespaces to separate products and rooms to separate games. Validate socket payloads with the same Zod schemas you use for HTTP, through a pipe.
Authenticate once in the handshake and store the user on socket.data. A WS guard then checks it on every message, and rooms scope who hears what.
In detail
A Socket.io middleware registered in afterInit verifies the JWT from handshake.auth and rejects bad tokens before the connection opens. Guards on handlers then read client.data.user, which avoids verifying the token on every message. Join each user to a personal room such as user:id so services can message them without tracking socket ids.
With more than one instance, a broadcast from one server must reach clients on the others. The Socket.io Redis adapter relays emits through Redis pub sub.
In detail
Create a custom IoAdapter that builds two Redis clients, one to publish and one to subscribe, and attaches the adapter. Register it in main.ts with app.useWebSocketAdapter. Your load balancer still needs sticky sessions if clients can fall back to long polling; forcing the websocket transport avoids that requirement.
OpenAPI describes request and response APIs. AsyncAPI describes event driven ones: channels, the messages clients send, and the messages the server emits.
In detail
nestjs-asyncapi reads decorators on gateways and payload classes and serves an AsyncAPI document and HTML viewer, much like Swagger. Describe each event as a channel with a publish or subscribe operation and a payload schema. If you prefer schemas from Zod, generate JSON Schema with zod-to-json-schema and write the YAML document by hand in docs/asyncapi.yaml.
Access tokens are short lived JWTs signed with a secret. A global guard verifies them on every request and attaches the user, and @Public opts routes out.
In detail
Use @nestjs/jwt directly for full control, or passport-jwt if you also use other Passport strategies. Keep access tokens short, around fifteen minutes, put only ids and roles in the payload, and set issuer and audience so tokens from another service are rejected. Read the token from the Authorization header with the Bearer scheme.
A long lived refresh token, stored hashed in Redis and rotated on every use, issues new access tokens. Reuse of an old refresh token revokes the whole session.
In detail
Give each login a session id. Store the hashed refresh token under session:user:sid with the refresh TTL. On refresh, compare, rotate to a new token, and keep the session id. If an already rotated token arrives, someone copied it, so delete the session. Send the refresh token as an httpOnly, secure, sameSite cookie scoped to the refresh path.
OAuth 2.0 with OpenID Connect lets users sign in with Google or GitHub. Passport strategies handle the redirect dance, and your callback issues your own JWT and session.
In detail
Use the authorization code flow with PKCE and a state parameter, which passport-google-oauth20 handles when state is enabled. Never trust the provider profile blindly: match on provider plus provider id, not email alone. After the callback, issue your tokens and redirect to the frontend with a one time code stored briefly in Redis, rather than placing tokens in the URL.
Authentication says who you are; authorization says what you may do. A roles guard reads required roles from metadata and compares them with the user's roles.
In detail
Start with roles and a typed @Roles decorator. When rules depend on the resource, for example editing only your own deck, move to policies: a function that receives the user and the loaded resource and returns a boolean. CASL is a popular library for that. Run authorization after authentication so the user is always present.
The client signs each request with a shared secret: an HMAC over the method, path, timestamp and body. Middleware recomputes it and rejects anything altered or replayed.
In detail
This is what SignatureSecretMiddleware does for the game client. Include a timestamp and reject requests older than a few minutes; add a nonce stored in Redis to stop exact replays inside that window. Hash the raw body, which needs rawBody: true in NestFactory.create, since re-serialised JSON may differ by whitespace or key order.
Wrap S3 in its own StorageModule with a client provider, an upload service and presigned URL helpers. Features import the module and never touch the AWS SDK directly.
In detail
Small files can stream through the API with putObject. Large ones should go straight from the browser to S3 with a presigned PUT URL, which saves bandwidth and memory. Generate keys yourself from a folder, a date and a random id, never from the original filename. Keep buckets private and serve reads through presigned GET URLs or a CDN with origin access control.
@nestjs/swagger builds an OpenAPI document from controllers and decorators and serves Swagger UI. With Zod, nestjs-zod or zod-to-openapi turns schemas into OpenAPI schemas.
In detail
Configure a DocumentBuilder with title, version, servers and security schemes, then call SwaggerModule.setup. Tag controllers, describe responses, and mark security per controller with the custom routing decorators. Exclude dashboard and internal routes. Export the JSON in CI so client SDKs can be generated and breaking changes caught in review.
A small admin dashboard can live inside the API: one HTML file and some JavaScript in a public folder, served by an assets controller and protected by the dashboard session guard.
In detail
ServeStaticModule is the quickest way to serve a folder. A dedicated assets controller gives more control: it can require a session, set cache headers, and stream files with StreamableFile. Remember to copy the public folder into dist with the assets entry in nest-cli.json, and exclude the dashboard paths from the global API prefix.
NestJS can render server side templates. Set EJS as the view engine on the Express adapter and decorate handlers with @Render to return a context object.
In detail
Server rendered pages suit admin screens, email previews and simple status pages where a frontend build is overkill. Keep views beside their feature or in a views folder, copy them with nest-cli assets, and escape everything by default with <%= rather than <%-. For a page that needs the template name at runtime, inject the response and call res.render yourself.
When a dashboard grows, give it its own module with sub folders for auth, constants, context, interfaces, schema, services and public assets, and split controllers by screen.
In detail
The WordLab dashboard keeps one controller per area: catalog, geography, image assets, lookup, preview and the page itself. Each controller stays small and maps to one screen of the HTML app. A single spec file covers the controllers with mocked services, and the module file wires the dashboard session guard, its services and its static assets. The tree below is the real layout.
WordLab dashboard
modules/wordlab/dashboard
authsession guard and login service
constants
contextrequest scoped dashboard context
interfaces
public
jsscreen scripts
wordlab-dashboard.htmlsingle page shell
schemaZod schemas per screen
servicescatalog, geography, lookup
wordlab-dashboard-assets.controller.tsserves HTML and JS
wordlab-dashboard-catalog.controller.ts
wordlab-dashboard-controllers.spec.tsone spec for all controllers
A small src/posthog module wraps the PostHog Node client as a provider, so features capture events through an injectable service and the client flushes on shutdown.
In detail
Capture events from services after the action succeeds, with the user id as the distinct id and only non sensitive properties. Call shutdown in onApplicationShutdown so batched events are not lost. Disable the client in tests by providing a no op implementation under the same token.
Unit tests check one class with fakes, integration tests check a module against real PostgreSQL and Redis, and end to end tests check the running app over HTTP and sockets.
In detail
Most tests should be fast unit tests of services, guards, pipes and schemas. Integration tests catch what mocks hide: SQL errors, missing indexes, wrong TTLs. A smaller set of end to end tests proves wiring: middleware exclusions, global guards and serialization. Name files by kind, .spec.ts or .test.ts for units and .e2e-spec.ts for end to end, so each runner picks its own.
Test.createTestingModule builds a tiny Nest module with the class under test and fakes for its dependencies. Jest mocks record calls and return controlled values.
In detail
Provide fakes by token with useValue, then get the real class from the compiled module. Use jest.Mocked to keep fakes typed. Test behaviour, not implementation: assert on results and on calls that matter, such as cache invalidation. getRepositoryToken(Word) gives the token TypeORM uses so you can replace a repository.
Vitest runs Jest style tests on Vite with native ESM and fast watch mode. NestJS needs one extra piece: SWC through unplugin-swc, because esbuild does not emit decorator metadata.
In detail
Without decorator metadata, Nest cannot read constructor types and every injection in a testing module fails. unplugin-swc fixes that. Use vi.fn and vi.mocked instead of jest.fn, keep globals on if you want describe and it without imports, and mirror tsconfig paths with vite-tsconfig-paths.
Schemas and event maps are small, but a wrong regex or a duplicated event name breaks a feature silently. Table driven tests cover them in a few lines.
In detail
bootstrap.schema.test.ts feeds valid and invalid payloads through each schema and asserts on the result and the issue path. bootstrap.events.test.ts checks that every event name is unique and namespaced. These tests need no Nest testing module at all, so they run in milliseconds.
2 examples
bootstrap.schema.test.tsTypeScript
it.each(invalid)("rejects %s", (_, input, path) => {const r = CreateWordSchema.safeParse(input);expect(r.success).toBe(false);});
import { describe, expect, it } from"vitest";import { CreateWordSchema, PaginatedQuerySchema, UpdateWordSchema } from"./bootstrap.schema";const valid = { text: "gato", gloss: "cat", meaning: "cat", level: "2" };describe("CreateWordSchema", () => {it("accepts a valid word and applies defaults", () => {const result = CreateWordSchema.parse(valid);expect(result).toMatchObject({ level: 2, script: "latin", tags: [] }); }); it.each([ ["empty text", { ...valid, text: "" }, "text"], ["uppercase gloss after trim is lowered, digits are not", { ...valid, gloss: "cat1" }, "gloss"], ["level out of range", { ...valid, level: 9 }, "level"], ["unknown key", { ...valid, extra: true }, ""], ])("rejects %s", (_name, input, path) => {const result = CreateWordSchema.safeParse(input);expect(result.success).toBe(false);if (!result.success) expect(result.error.issues[0].path.join(".")).toBe(path); });});describe("UpdateWordSchema", () => {it("needs at least one field", () => {expect(UpdateWordSchema.safeParse({}).success).toBe(false); });});describe("PaginatedQuerySchema", () => {it("coerces query strings", () => {expect(PaginatedQuerySchema.parse({ page: "3", limit: "10" })).toEqual({ page: 3, limit: 10 }); });});
Why it matters it.each turns a list of bad inputs into named tests, so a failure names the exact case.
bootstrap.events.test.tsTypeScript
expect(newSet(names).size).toBe(names.length);
import { describe, expect, it } from"vitest";import { BootstrapEvents } from"./bootstrap.events";describe("BootstrapEvents", () => {const names = Object.values(BootstrapEvents);it("has unique names", () => {expect(newSet(names).size).toBe(names.length); });it("namespaces every event under bootstrap.", () => {for (const name of names) expect(name).toMatch(/^bootstrap\.[a-z.-]+$/); });});
Why it matters A two assertion test is enough to stop a copy pasted event name from merging two unrelated listeners.
When you compile a real module in tests, overrideProvider, overrideGuard, overrideInterceptor and overrideModule replace selected pieces while keeping the rest of the wiring.
In detail
This is the bridge between unit and end to end tests: you import the real BootstrapModule but swap the S3 client, the mail transport or the JWT guard. useMocker fills any dependency you did not list with an automatic fake, which is handy for large constructors.
Testcontainers starts real PostgreSQL and Redis in Docker for the duration of a test file. Your module runs its real queries, migrations and TTLs against them.
In detail
Start containers in beforeAll, pass their URLs to the app through config, run migrations, and stop everything in afterAll. Truncate tables between tests rather than restarting containers. These tests catch the bugs mocks cannot: a wrong column type, a missing unique index, a Redis key without expiry.
End to end tests start the full Nest application and send real HTTP requests with Supertest. They prove global pipes, guards, middleware exclusions and filters work together.
In detail
Build the app with the same setup function main.ts uses, so prefixes, versioning and pipes match production. Then assert on status codes, headers and bodies. Test the edges that unit tests miss: an excluded path that skips the signature middleware, a 429 after too many OTP requests, a 400 shape from the Zod pipe.
Start the app on a random port and connect with socket.io-client. Emit an event with an acknowledgement and assert on the reply and on broadcasts.
In detail
Listen on port 0 so tests never collide, read the address back, and connect to the namespace. Wrap emits with emitWithAck for request reply checks. For broadcasts, connect two clients, act with one, and wait for the event on the other with a small promise helper and a timeout.
Nest's Logger is fine for development. In production use structured JSON logs with Pino through nestjs-pino, with request ids and redacted secrets.
In detail
nestjs-pino replaces the app logger and adds automatic request logging. Each log line is JSON with level, time, request id and context, which log platforms index directly. Redact authorization headers, cookies and passwords at the logger level so nobody can log them by accident. Keep using new Logger(Class.name) in your code; the calls route through Pino.
@nestjs/terminus exposes health endpoints that check the database, Redis, disk and memory. Orchestrators use them to decide when to send traffic and when to restart.
In detail
Expose two endpoints. Liveness answers whether the process is responsive and should check nothing external, or a slow database would restart healthy pods. Readiness checks dependencies and tells the load balancer whether to route traffic. Exclude both from the global prefix, auth and throttling.
When the platform sends SIGTERM, stop accepting new requests, let in flight work finish, close workers, queues and connections, then exit.
In detail
enableShutdownHooks makes Nest run onModuleDestroy, beforeApplicationShutdown and onApplicationShutdown on signals. Close BullMQ workers so active jobs finish or return to the queue, disconnect sockets with a reason, then close Redis and the database. Set the platform's termination grace period longer than your slowest request.
Helmet sets protective headers, CORS limits which origins may call you, validation rejects unknown input, and secrets come from validated config. Add CSRF protection when you use cookies for auth.
In detail
Start from the OWASP API Security Top 10. Validate everything with strict schemas, authorize every object access, rate limit authentication and OTP routes, and never return stack traces. Set trust proxy correctly so IP based limits see real clients. Keep dependencies current and run npm audit in CI.
NestJS is platform agnostic. Swap @nestjs/platform-express for @nestjs/platform-fastify and most controllers, guards and pipes keep working, with higher throughput.
In detail
Express specific code is what breaks: middleware that touches res directly, Multer based file interceptors, and @Res handlers using Express methods. Use @fastify/multipart for uploads and Fastify plugins such as @fastify/helmet. Benchmark your real endpoints; the gain is largest for small JSON responses.
A multi stage Dockerfile builds with dev dependencies and ships a small runtime image with only production dependencies. Compose runs PostgreSQL, Redis and RabbitMQ beside it for local work.
In detail
Copy the lockfile first so dependency layers cache, build, prune dev dependencies, then copy dist into a slim image that runs as a non root user. Add a HEALTHCHECK that hits the liveness endpoint. In Compose, wait for dependency health before starting the API.
2 examples
DockerfileDockerfile
FROM node:22-alpine AS buildRUN pnpm build && pnpm prune --prodFROM node:22-alpineUSER node
@nestjs/microservices lets a Nest app talk over Redis, RabbitMQ, Kafka, NATS, gRPC or TCP with @MessagePattern for request reply and @EventPattern for fire and forget.
In detail
A hybrid app serves HTTP and listens on a transport at the same time with connectMicroservice. Split a service only when a module has its own scaling, release or ownership needs; a well bounded module in a modular monolith is cheaper to run. Clients inject ClientProxy and use send for replies and emit for events.
CQRS splits writes into commands and reads into queries, each with its own handler. Events record what happened and sagas react to them.
In detail
Use it where reads and writes truly differ, for example a game round with many writes and a leaderboard read model. Commands are classes carrying intent, handlers do the work, and the EventBus publishes domain events after commit. For simple CRUD, plain services remain clearer.
nest generate app and nest generate library turn a project into a monorepo with several apps sharing libraries such as auth, redis and contracts.
In detail
Libraries get path aliases like @app/redis, so the API, the worker and an admin app import the same code. Run the queue worker as its own app that imports the same feature modules but no controllers. For larger setups, Nx or Turborepo add caching and affected only builds on top.
# convert a standard project into a monorepo by adding appsnest generate app workernest generate app admin# shared code as libraries, imported as @app/<name>nest generate library redisnest generate library contractsnest generate library auth# run each appnest start api --watchnest start worker --watch# resulting layout# apps/api/src/main.ts# apps/worker/src/main.ts (no HTTP, only processors and crons)# libs/redis/src/index.ts# libs/contracts/src/index.ts
Why it matters The worker app imports the same modules as the API but starts with createApplicationContext, so it opens no port.
Build a small realtime word bingo game that uses every phase of this roadmap. If you can ship it, test it and explain each file, you have mastered NestJS for real work.
In detail
Scope: players sign in with OTP or Google, start a bingo room over Socket.io, draw words from sharded PostgreSQL tables, and earn XP. Cache the word pool in Redis, send welcome email through BullMQ, publish level ups to RabbitMQ, upload avatars to S3, document REST with OpenAPI and sockets with AsyncAPI, and serve a small admin dashboard. Prove it with Vitest units, a Testcontainers integration suite and Supertest plus socket e2e tests, then ship it in Docker with health checks and graceful shutdown.
Why it matters Read this file top to bottom and you can name the module in this roadmap behind every line.
Questions people ask
Short answers to the questions that come up most while learning NestJS, each linked to the module that covers it in depth.
Do I need to know Express before NestJS?
It helps but is not required. NestJS uses Express underneath by default, so knowing how requests, middleware and responses work in Express makes the request lifecycle easier to follow. You can learn both side by side.
Either works well. class-validator integrates with Swagger out of the box through DTO classes. Zod gives one source of truth for validation and types, richer transforms, and the same schemas on the frontend. Pick one per codebase and stay consistent.
The provider is not visible in that module. It is either missing from providers, not exported by the module you import, typed as an interface without a token, or undefined because of an import cycle. The index in the message tells you which constructor argument failed.
Middleware, guard or interceptor: how do I choose?
Use middleware for raw request work that does not need the handler, guards for allow or deny decisions, pipes for input shape, and interceptors for anything that wraps or changes the result.
Yes for teams that want decorators and entities that match Nest's style. Prisma, Drizzle and MikroORM are solid alternatives; the module, provider and testing patterns in this roadmap apply to all of them.
BullMQ for background jobs inside one application, with retries and scheduling backed by Redis. RabbitMQ when several services publish and consume messages and need routing between them.
Both work. Vitest is faster and ESM native but needs unplugin-swc for decorator metadata. Jest is the default the CLI generates and has the largest set of examples. The testing module API is identical in both.
Use the Redis adapter so broadcasts reach clients on every instance, and either force the websocket transport or enable sticky sessions on the load balancer.
Through ConfigService or typed config namespaces, never process.env inside services. Validate once at startup so a missing secret stops the app before it serves traffic.
Usually one. Split when a part has a different owner, different dependencies, or would be reused elsewhere, such as a dashboard inside a larger product area.
No. A modular monolith with clear module boundaries gets most of the benefit. The same boundaries make it straightforward to extract a service later if scaling or ownership demands it.
Every source linked from the modules above, grouped by the phase that uses it and then by where it lives. 208 links in total, all opening in a new tab.