nestjs · · 18 min read
NestJS for Beginners: From Your First Project to a Task API
Start a NestJS project, then use Controller, Service, Module, and DTO to build a testable task API and see how a request is handled.
NestJS is more than decorator syntax. Its real value is a clear split among module boundaries, dependency injection, and a standard request pipeline. That structure makes a backend easier to test, easier to change, and easier to work on with other people.
This article is for readers who already know JavaScript or TypeScript, understand HTTP and npm, and want a first Node.js backend framework. You do not need Express experience to follow along.
What is NestJS, and why start here?
NestJS is a framework for building efficient, scalable Node.js server applications. It is written in TypeScript and fully supports it, while still allowing plain JavaScript. It sits on Express by default and can switch to Fastify. The important part is the architecture it adds on top of that HTTP layer, without hiding the platform completely.
If you have ever watched an Express file slowly absorb routing, validation, database queries, auth, and error handling, you have already met the problem NestJS is designed to solve: architecture is not “the code runs.” It is “the code can keep changing.” The design is inspired by Angular and pushes toward testable, loosely coupled, maintainable structure.
| Dimension | Common habit with a raw HTTP framework | NestJS default | Why that helps beginners |
|---|---|---|---|
| Routing | Register paths and callbacks by hand | Declare routes with a Controller and HTTP-method decorators | You can see which API belongs where |
| Business logic | Easy to bury inside route callbacks | Move it into a Service / Provider | Reuse and unit tests get easier |
| Dependencies | Manual new or passing instances around | The IoC container injects them | Less object-assembly noise |
| Organization | Group by file type, or pile files together | Split by feature Module | Easier to grow with a team |
| Input safety | Validation is easy to skip | DTO + Pipe can be applied once | Reject bad data at the boundary |
That does not mean Express is obsolete, or that NestJS is the right tool for every project. A tiny script, a one-off API, or a service that needs complete freedom may stay simpler on the raw framework. NestJS is a better fit when you want engineering habits from day one, and you expect more endpoints, more features, or more collaborators.
Before you start: environment and the first project
The official docs currently require Node.js v20.19 or later to run a Nest app, or v22.12+ on the 22.x line. When you create a project with the CLI, install the latest Active LTS.
For beginners, the Nest CLI is the least painful start. It prepares a normal TypeScript project, starter source files, and tests. Pass --strict if you want TypeScript to surface type issues earlier. The CLI also asks whether you want CommonJS or ESM; ESM starters default to Vitest and oxlint. The examples below follow the usual npm run start:dev / npm run test flow, which works with either starter.
# Install the Nest CLI
npm i -g @nestjs/cli
# Create a project. --strict is worth it for beginners.
nest new nest-beginner --strict
cd nest-beginner
npm run start:dev
Then open http://localhost:3000/. In development mode, the app recompiles and restarts when files change. Confirm this smallest loop with a browser, curl, or an API client before you add a database or JWT.
After the CLI finishes, src/ only has a few core files. Understanding their jobs is the first real NestJS hurdle.
| File | How to read it at the start | How you will usually change it |
|---|---|---|
main.ts | The bootstrap entry. Creates the app and listens on a port | Global pipes, global prefix, CORS |
app.module.ts | The root module. Nest builds the application graph from here | Import feature modules |
app.controller.ts | A demo controller | Usually replaced by feature controllers |
app.service.ts | A demo service | Usually replaced by feature services |
app.controller.spec.ts | A sample controller unit test | Keep it and extend tests by feature |
The two important lines in main.ts are NestFactory.create(AppModule) and app.listen(...). The first builds a Nest app from the root module. The second starts HTTP listening. Think of the root module as the assembly list, and main.ts as the start button.
Three core ideas: Controller, Provider, and Module
The most common beginner mistake is memorizing @Get() and @Post() before knowing which boundary those decorators belong in. A more stable model is: Controller faces HTTP. Provider faces business work. Module faces feature boundaries.
| Concept | Question it answers | Typical contents | What it should not own |
|---|---|---|---|
| Controller | “Which endpoint receives this request?” | Path, HTTP method, parameter reading, status codes, response contract | Complex business rules, database details |
| Provider / Service | “How is this work actually done?” | Business rules, coordinating data access, calling other services | Declaring HTTP routes |
| Module | “Which capabilities belong together, and who can use whom?” | controllers, providers, imports, exports | The business algorithm itself |
A Controller handles incoming requests and sends responses. @Controller('tasks') prefixes related routes with /tasks. Method decorators such as @Get() and @Post() then choose the HTTP method and the remaining path. In the default standard response mode, returned objects and arrays are serialized to JSON. Regular handlers default to 200; POST defaults to 201.
A Provider is an injectable dependency. Services, repositories, factories, and helpers can all be providers. A class marked @Injectable() can be managed by Nest’s IoC container and injected through a constructor into a controller or another service. Business classes do not new their dependencies everywhere. Nest assembles them from the declared graph.
A Module is the encapsulation boundary. Every Nest app has at least one root module. Nest builds the application graph from that root so it can resolve modules and providers. Providers are encapsulated by default. Another module can use one only after it imports the owning module and that provider is listed in exports. In that sense, exports is the module’s public API.
Build a small, well-structured Tasks API
The next section builds a Tasks feature with no database. Tasks live in a memory array so you can focus on NestJS structure instead of ORM setup. Data disappears after a restart. That is expected in this demo.
Install the validation packages first. The official ValidationPipe depends on class-validator and class-transformer, so they must be installed explicitly.
npm i class-validator class-transformer
# Optional: let the CLI create the files, then fill them in below
nest g module tasks
nest g controller tasks
nest g service tasks
Set one rule at the application entrance
Enable global validation in src/main.ts. whitelist: true strips properties that have no validation decorators on the DTO. Combined with forbidNonWhitelisted: true, a request with extra fields fails. transform: true lets the framework convert values using the DTO or parameter types.
// src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Use a DTO as the input contract
A DTO (Data Transfer Object) is not a database entity, and it is not a casual TypeScript type alias. It is the input contract at the API boundary: what the client must send, and which rules those fields must satisfy. Define DTOs as classes. The official docs note that TypeScript interfaces and generics do not keep runtime metadata, so ValidationPipe may not validate them correctly.
// src/tasks/dto/create-task.dto.ts
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class CreateTaskDto {
@IsString()
@IsNotEmpty()
@MaxLength(120)
title!: string;
}
Put business behavior in a Service
A Service does not care whether the call arrived over HTTP, a message queue, or the CLI. It only cares about actions such as “create a task” or “list tasks.” The Task type below is an internal return model, so a TypeScript interface is fine. The input that needs runtime validation is still the DTO class.
// src/tasks/tasks.service.ts
import { Injectable } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
export interface Task {
id: number;
title: string;
completed: boolean;
}
@Injectable()
export class TasksService {
private readonly tasks: Task[] = [];
private nextId = 1;
findAll(): Task[] {
return this.tasks;
}
create(input: CreateTaskDto): Task {
const task: Task = {
id: this.nextId++,
title: input.title,
completed: false,
};
this.tasks.push(task);
return task;
}
}
@Injectable() tells Nest that the IoC container can manage this class. Providers usually live for the application lifetime. Request-scoped providers exist for more complex cases, but you do not need them on day one just to look advanced.
Let the Controller map HTTP to business calls
The controller should stay thin: read data from the request, call the service, return the result. @Body() is a dedicated parameter decorator. @Param() and @Query() are the usual companions. That is clearer than reading the raw request object, and it is easier to validate and test.
// src/tasks/tasks.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { CreateTaskDto } from './dto/create-task.dto';
import { Task, TasksService } from './tasks.service';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll(): Task[] {
return this.tasksService.findAll();
}
@Post()
create(@Body() dto: CreateTaskDto): Task {
return this.tasksService.create(dto);
}
}
Look at TasksService in the constructor. You are not creating the service yourself. You are declaring what the controller depends on. Once the service is registered in the module, Nest resolves and injects it.
Assemble and encapsulate with a Module
Put the controller and service in one feature module, then import that module from the root. providers are the dependencies this module’s injector can create. controllers are this module’s controllers. imports bring in capabilities exported by other modules.
// src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}
// src/app.module.ts
import { Module } from '@nestjs/common';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [TasksModule],
})
export class AppModule {}
The directory now groups by capability: tasks holds that feature’s DTO, Controller, Service, and Module. As the project grows, add users, auth, and orders the same way. The root module only composes them. That is the point of a feature module.
src/
├── main.ts
├── app.module.ts
└── tasks/
├── dto/
│ └── create-task.dto.ts
├── tasks.controller.ts
├── tasks.module.ts
└── tasks.service.ts
Check the API
Run npm run start:dev, then use another terminal for the commands below. The first request should return 201 and a new task. The second should return an array. The third request deliberately sends an extra field; with this article’s global validation settings, it should return 400. That proves the input boundary is working.
curl -X POST http://localhost:3000/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"Learn NestJS module boundaries"}'
curl http://localhost:3000/tasks
curl -X POST http://localhost:3000/tasks \
-H 'Content-Type: application/json' \
-d '{"title":"Extra-field example", "isAdmin": true}'
What happens to a request inside NestJS?
Once you can write a Controller and a Service, the next question is not “learn fifty more decorators.” It is “when does a request pass through which layer?” Official docs call this the request lifecycle. A request usually travels through middleware, guards, interceptors, pipes, then the controller and service. After the response is produced, it returns through interceptors. Uncaught exceptions go to an exception filter.
Request
│
├── Middleware earliest preprocessing: logs, simple context
├── Guard may this request enter: auth, roles, permissions
├── Interceptor (before) wrap the handler: timing, cache, response shape
├── Pipe transform and inspect: DTO validation, string → number
├── Controller → Service
├── Interceptor (after) the response travels back through the same wrap
└── Exception Filter only uncaught exceptions
Bindings can also be global, controller-level, or route-level. Use the official request-lifecycle page as the source of truth for the complete order.
| Mechanism | Treat it as | Best at | Boundary to remember first |
|---|---|---|---|
| Middleware | The earliest preprocessing layer | Request logs, simple context | Not the place for route-level authorization |
| Guard | “May this request enter?” | Authentication, roles, permissions | Return whether processing may continue |
| Interceptor | A wrapper around the handler | Uniform responses, timing, caching | Can run before and after the handler |
| Pipe | A converter and inspector for input | DTO validation, string-to-number | Reject invalid input early |
| Exception Filter | The exit for uncaught exceptions | Error response shape, exception mapping | Only uncaught exceptions |
Two details are easy to mix up. First, guards usually run before pipes, so authentication and authorization belong in a Guard, not in DTO validation “for consistency.” Second, a filter runs only for uncaught exceptions, and matching priority goes from route to controller to global, not the more common “global wins first” pattern.
Six common mistakes, and better starting points
| Mistake | Why it becomes hard to maintain | Better start |
|---|---|---|
| Put queries, rules, and third-party calls in the Controller | HTTP details couple to business rules; tests and reuse suffer | Let the Controller call a Service; let the Service coordinate work |
new a Service inside a Controller | You bypass the IoC container; mocks get harder | Inject through the constructor and register the provider |
| Use an interface as the input DTO | Interfaces disappear at runtime; validators lose metadata | Use a DTO class with validation decorators |
| Trust frontend validation alone | Any client can skip the UI and call the API | Use ValidationPipe and DTOs at the entrance |
Make every module global because exports feels confusing | Dependency sources go dark; module boundaries stop meaning anything | Encapsulate by default; export only what is truly shared |
Inject @Res() too early and build responses by hand | You give up standard response handling; mixing it with auto-serialization can break | return data from most routes |
The shared goal is a clear dependency path: HTTP enters the Controller, work lives in the Service, capability belongs to a Module, and input is validated at the boundary. When you ask “where should this code go?”, those four sentences usually help more than searching for another decorator.
Testing: confirm the business behavior first
Tests are not extra code for its own sake. They let you change a feature and quickly check that old behavior still holds. In the tasks module, the important question is not “can the page or HTTP request open?” It is “after creating a task, do we get the right data back?” That kind of check is a unit test.
NestJS structure is a good fit: business rules live in the Service, and the Controller only receives the request and calls the Service. You can test TasksService alone, without starting HTTP and without a real database. The tests run faster, and failures point at the logic.
The example below checks that create() returns the right task. beforeEach runs before every test and builds a fresh TasksService, so leftover data from the previous test cannot leak in.
// src/tasks/tasks.service.spec.ts
import { Test } from '@nestjs/testing';
import { TasksService } from './tasks.service';
describe('TasksService', () => {
let service: TasksService;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [TasksService],
}).compile();
service = moduleRef.get(TasksService);
});
it('creates a task with an incrementing id', () => {
const task = service.create({ title: 'Write the first test' });
expect(task).toEqual({
id: 1,
title: 'Write the first test',
completed: false,
});
});
});
Read the setup in this order:
| Code | Role | Plain reading |
|---|---|---|
Test.createTestingModule() | Create a Nest module used only for tests | Build a small, isolated runtime |
providers: [TasksService] | Register the service under test | Tell Nest: “this test only needs this service” |
compile() | Finish initializing the testing module | Let Nest create and prepare instances |
moduleRef.get(TasksService) | Retrieve the service | Get the TasksService you will assert on |
expect(...).toEqual(...) | Compare actual result with expected result | Decide whether the behavior is correct |
In a scaffolded project, run tests with npm run test. You do not need full coverage at the start. Write one or two tests for the most important methods in each Service. Create, complete, and delete are enough of a beginning.
When a Service later talks to a database, a third-party API, or a message bus, do not connect those real systems in unit tests. Inject those capabilities as providers, then replace them with mocks. The test should keep verifying business logic, not fail because the network, the database, or a vendor is down.
NestJS microservices: when several services work together
A microservice, in plain terms, is an independent slice of a larger app that runs on its own. Task management, notifications, and audit logs can each become a service. They do their own work, then talk through messages.
Microservices are not the only answer after a project grows. At the start, the more useful skill is a clean module split inside one NestJS app. Split a feature into its own process only when it needs its own release cycle, its own scale, or truly separate ownership.
Browser
│
└── API service (HTTP)
│ send / emit
├── Tasks service
│ │ emit task.created
├── Notifications service
└── Audit service
The user request enters the public API service first. That service calls the tasks service. After a task is created, it can notify the notifications and audit services.
When do you actually need microservices?
Starting with one NestJS app is not “behind.” For a small product whose rules still change often, one process is usually easier to build and debug. Microservices should solve a concrete problem, not decorate an architecture diagram.
| Current situation | Better move | Why |
|---|---|---|
| You only want tidier code | Use Module and Service | Modules already separate features |
| One capability is hot, such as notifications or file processing | Consider a dedicated service | That service can scale on its own |
| After an order you need notify, award points, and write an audit log | Emit an event to several services | The main flow does not wait for every side effect |
| Separate teams own relatively stable capabilities | Consider splitting by capability | Teams can change and release independently |
| The feature is still changing every week | Keep it in one app | Early splits raise integration and debugging cost |
Simple rule: split features into clear NestJS modules first. Then decide whether any of them need their own process. Separate files are not the same as separate processes.
Two common ways services talk
Services do not always “send a request and wait.” NestJS has two common patterns.
| Pattern | When to use it | NestJS API | Plain reading |
|---|---|---|---|
| Call and wait | You need the answer now, such as listing tasks or checking stock | @MessagePattern() and client.send() | “Please do this, then tell me the result.” |
| Notify | You only need to announce that something happened | @EventPattern() and client.emit() | “A task was created. Whoever cares can handle it.” |
When a user opens the task list, the API service needs data from the tasks service, so it uses send(). After a task is created, notifications or audit logs can subscribe to task.created. Those side effects should not block the user’s request.
Step 1: create a service that only owns tasks
Install the NestJS microservices package:
npm i @nestjs/microservices
Create a tasks-service project. It no longer exposes /tasks over HTTP. It waits for messages from other services. The example uses TCP for local learning because you do not need a message broker.
// tasks-service/src/main.ts
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { TasksModule } from './tasks/tasks.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
TasksModule,
{
transport: Transport.TCP,
options: {
host: process.env.TASKS_HOST ?? '127.0.0.1',
port: Number(process.env.TASKS_PORT ?? 8877),
},
},
);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen();
}
bootstrap();
Only two facts matter here: Transport.TCP means this example talks over TCP, and port: 8877 is where the tasks service waits. You can keep the earlier TasksService and DTO. Replace the HTTP controller with a message controller.
// tasks-service/src/tasks/tasks.message-controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { CreateTaskDto } from './dto/create-task.dto';
import { Task, TasksService } from './tasks.service';
@Controller()
export class TasksMessageController {
constructor(private readonly tasksService: TasksService) {}
@MessagePattern({ cmd: 'tasks.findAll' })
findAll(): Task[] {
return this.tasksService.findAll();
}
@MessagePattern({ cmd: 'tasks.create' })
create(@Payload() dto: CreateTaskDto): Task {
return this.tasksService.create(dto);
}
}
{ cmd: 'tasks.create' } in @MessagePattern() is the message name. The caller sends the same name, and NestJS routes the message to that method. Register this controller in TasksModule. Keep the business logic in TasksService.
// tasks-service/src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { TasksMessageController } from './tasks.message-controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksMessageController],
providers: [TasksService],
})
export class TasksModule {}
Step 2: create an API service that still speaks HTTP
Browsers and frontends still talk HTTP. Keep one API service that accepts /tasks, then forwards the work to the tasks service. People often call this an API gateway. For now, treat it as a thin forwarding layer.
Register the connection to the tasks service:
// api-gateway/src/tasks/tasks.gateway.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { TasksGatewayController } from './tasks.gateway.controller';
@Module({
imports: [
ClientsModule.register([
{
name: 'TASKS_SERVICE',
transport: Transport.TCP,
options: {
host: process.env.TASKS_HOST ?? '127.0.0.1',
port: Number(process.env.TASKS_PORT ?? 8877),
},
},
]),
],
controllers: [TasksGatewayController],
})
export class TasksGatewayModule {}
Import that feature module from the gateway root:
// api-gateway/src/app.module.ts
import { Module } from '@nestjs/common';
import { TasksGatewayModule } from './tasks/tasks.gateway.module';
@Module({
imports: [TasksGatewayModule],
})
export class AppModule {}
Then inject TASKS_SERVICE in the controller and turn HTTP into messages. The message names must match across projects. { cmd: 'tasks.findAll' } below is the same name the tasks service listens for.
// api-gateway/src/tasks/tasks.gateway.controller.ts
import { Body, Controller, Get, Inject, Post } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { CreateTaskDto } from './dto/create-task.dto';
@Controller('tasks')
export class TasksGatewayController {
constructor(
@Inject('TASKS_SERVICE') private readonly tasksClient: ClientProxy,
) {}
@Get()
findAll() {
return this.tasksClient.send({ cmd: 'tasks.findAll' }, {});
}
@Post()
create(@Body() dto: CreateTaskDto) {
return this.tasksClient.send({ cmd: 'tasks.create' }, dto);
}
}
Start tasks-service first, then api-gateway. After that, GET /tasks makes the API service send tasks.findAll to the tasks service. The result comes back to the API service and is returned as the HTTP response.
Step 3: notify other services with events
If creating a task should also send a notification and write an audit log, do not make the tasks service call every other service synchronously. Emit a “task created” event and let interested services handle it.
A notifications service can listen like this:
// notifications-service/src/tasks-events.controller.ts
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
interface TaskCreatedEvent {
taskId: number;
title: string;
}
@Controller()
export class TasksEventsController {
@EventPattern('task.created')
async notify(@Payload() event: TaskCreatedEvent) {
console.log(`Notify task created: ${event.taskId} ${event.title}`);
}
}
The tasks service emits with client.emit('task.created', event). The notifications service receives it with @EventPattern('task.created'). Later, an audit service or a points service can subscribe to the same event without changing the main tasks flow.
You can try this inside one project first
If you do not want to maintain several repositories yet, one NestJS app can accept HTTP and microservice messages at the same time. That is useful for practice or a gradual move: prove the message path, then split only after the boundaries stabilize.
// One app serving HTTP and a TCP microservice
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.connectMicroservice<MicroserviceOptions>(
{
transport: Transport.TCP,
options: { port: 8877 },
},
{ inheritAppConfig: true },
);
await app.startAllMicroservices();
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
{ inheritAppConfig: true } reuses the main app’s global setup, including the ValidationPipe from earlier. Without it, the microservice does not automatically inherit global pipes, guards, or interceptors.
When should you add RabbitMQ?
The TCP example is enough to learn how services call each other. Add a broker such as RabbitMQ when one service must emit an event that several services should receive, or when you need more reliable async work.
npm i amqplib amqp-connection-manager
RabbitMQ has one idea you should learn early: acknowledgement. After a consumer finishes a message, it tells RabbitMQ “this one is done.” RabbitMQ deletes the message only after that ack. If the service dies before acking, the message can be delivered again to another available consumer.
// notifications-service/src/notifications.controller.ts
import { Controller } from '@nestjs/common';
import {
Ctx,
MessagePattern,
Payload,
RmqContext,
} from '@nestjs/microservices';
@Controller()
export class NotificationsController {
@MessagePattern('notifications')
async handle(
@Payload() payload: { taskId: number },
@Ctx() context: RmqContext,
) {
await this.sendNotification(payload);
// Ack only after the business work succeeds.
const channel = context.getChannelRef();
const message = context.getMessage();
channel.ack(message);
}
private async sendNotification(_payload: { taskId: number }) {
// Call the real notification channel
}
}
Manual ack requires noAck: false in the transport options. A real system also has to deal with duplicate delivery, timeouts, and retries.
| Before production | In plain words |
|---|---|
| Timeouts and retries | Do not wait forever if one service is silent |
| Idempotency | The same message may arrive twice; do not charge or reward twice |
| Logs and monitoring | You must see which services a message passed through |
| Message contracts | Services must agree on field names and shapes; changes should not break old consumers |
| Failed-message handling | Repeated failures need a dedicated place and a human process |
The hard part of microservices is not the syntax. It is what happens when several services fail at once. Start with the smallest path: API service calls tasks service, then the tasks service emits one event to a notifications service. After that path works, add a broker, monitoring, and deployment automation.
What to learn next: one small project, then more features
You do not need to learn databases, login, Docker, microservices, and GraphQL in one sitting. Finish a small project first, then add one capability at a time. Each new idea should solve a problem you can already see.
Keep using this article’s Tasks API as the practice project. Make create, read, update, and delete work first. Then add a database, login, and more tests. The sequence below is a guide, not a schedule.
| Stage | What you can build | What you should understand |
|---|---|---|
| 1 | Full task CRUD with input validation | The split among Controller, Service, Module, DTO, and Pipe |
| 2 | A real database | How the Service talks to storage, and how errors surface |
| 3 | Login and protected endpoints | How a Guard decides whether a user may enter |
| 4 | Tests for the main paths, plus API docs | How you know a change did not break old behavior |
| Later | Cache, queues, WebSocket, microservices, or GraphQL as needed | Pick the tool that matches a real need |
Self-check: if you can explain why an endpoint lives in a Controller, why a rule lives in a Service, and why a feature needs its own Module, you already have the most important NestJS habit.
Closing
You do not need every decorator and every config option. Start with placement: request-facing code in the Controller, business work in the Service, related capability in a Module, and user input checked with DTOs and Pipes.
Once that split is clear, databases, login, tests, and later microservices get easier. Start from this Tasks API. Get one small feature running, then improve it. Writing one complete project teaches more than collecting templates.
References
Mttao GitHub ↗
Exploring technology and life's wisdom
Related Posts
View all →- 01 NestJS vs Next.js: Backend Framework, React Full Stack, or Both? nextjs· Aug 25, 2026
- 02 Claude Code vs Cursor: Which AI Coding Environment Fits Daily Web Development? ai· Jul 22, 2026
- 03 The Three Pillars of Modern Distributed Communication: An Architectural Analysis of HTTP, WebSocket, and gRPC frontend· Nov 30, 2025