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.

Mttao Mttao @bearboy80 3,890 words 中文 →
NestJS for Beginners: From Your First Project to a Task API

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.

DimensionCommon habit with a raw HTTP frameworkNestJS defaultWhy that helps beginners
RoutingRegister paths and callbacks by handDeclare routes with a Controller and HTTP-method decoratorsYou can see which API belongs where
Business logicEasy to bury inside route callbacksMove it into a Service / ProviderReuse and unit tests get easier
DependenciesManual new or passing instances aroundThe IoC container injects themLess object-assembly noise
OrganizationGroup by file type, or pile files togetherSplit by feature ModuleEasier to grow with a team
Input safetyValidation is easy to skipDTO + Pipe can be applied onceReject 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.

FileHow to read it at the startHow you will usually change it
main.tsThe bootstrap entry. Creates the app and listens on a portGlobal pipes, global prefix, CORS
app.module.tsThe root module. Nest builds the application graph from hereImport feature modules
app.controller.tsA demo controllerUsually replaced by feature controllers
app.service.tsA demo serviceUsually replaced by feature services
app.controller.spec.tsA sample controller unit testKeep 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.

ConceptQuestion it answersTypical contentsWhat it should not own
Controller“Which endpoint receives this request?”Path, HTTP method, parameter reading, status codes, response contractComplex business rules, database details
Provider / Service“How is this work actually done?”Business rules, coordinating data access, calling other servicesDeclaring HTTP routes
Module“Which capabilities belong together, and who can use whom?”controllers, providers, imports, exportsThe 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.

MechanismTreat it asBest atBoundary to remember first
MiddlewareThe earliest preprocessing layerRequest logs, simple contextNot the place for route-level authorization
Guard“May this request enter?”Authentication, roles, permissionsReturn whether processing may continue
InterceptorA wrapper around the handlerUniform responses, timing, cachingCan run before and after the handler
PipeA converter and inspector for inputDTO validation, string-to-numberReject invalid input early
Exception FilterThe exit for uncaught exceptionsError response shape, exception mappingOnly 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

MistakeWhy it becomes hard to maintainBetter start
Put queries, rules, and third-party calls in the ControllerHTTP details couple to business rules; tests and reuse sufferLet the Controller call a Service; let the Service coordinate work
new a Service inside a ControllerYou bypass the IoC container; mocks get harderInject through the constructor and register the provider
Use an interface as the input DTOInterfaces disappear at runtime; validators lose metadataUse a DTO class with validation decorators
Trust frontend validation aloneAny client can skip the UI and call the APIUse ValidationPipe and DTOs at the entrance
Make every module global because exports feels confusingDependency sources go dark; module boundaries stop meaning anythingEncapsulate by default; export only what is truly shared
Inject @Res() too early and build responses by handYou give up standard response handling; mixing it with auto-serialization can breakreturn 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:

CodeRolePlain reading
Test.createTestingModule()Create a Nest module used only for testsBuild a small, isolated runtime
providers: [TasksService]Register the service under testTell Nest: “this test only needs this service”
compile()Finish initializing the testing moduleLet Nest create and prepare instances
moduleRef.get(TasksService)Retrieve the serviceGet the TasksService you will assert on
expect(...).toEqual(...)Compare actual result with expected resultDecide 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 situationBetter moveWhy
You only want tidier codeUse Module and ServiceModules already separate features
One capability is hot, such as notifications or file processingConsider a dedicated serviceThat service can scale on its own
After an order you need notify, award points, and write an audit logEmit an event to several servicesThe main flow does not wait for every side effect
Separate teams own relatively stable capabilitiesConsider splitting by capabilityTeams can change and release independently
The feature is still changing every weekKeep it in one appEarly 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.

PatternWhen to use itNestJS APIPlain reading
Call and waitYou need the answer now, such as listing tasks or checking stock@MessagePattern() and client.send()“Please do this, then tell me the result.”
NotifyYou 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 productionIn plain words
Timeouts and retriesDo not wait forever if one service is silent
IdempotencyThe same message may arrive twice; do not charge or reward twice
Logs and monitoringYou must see which services a message passed through
Message contractsServices must agree on field names and shapes; changes should not break old consumers
Failed-message handlingRepeated 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.

StageWhat you can buildWhat you should understand
1Full task CRUD with input validationThe split among Controller, Service, Module, DTO, and Pipe
2A real databaseHow the Service talks to storage, and how errors surface
3Login and protected endpointsHow a Guard decides whether a user may enter
4Tests for the main paths, plus API docsHow you know a change did not break old behavior
LaterCache, queues, WebSocket, microservices, or GraphQL as neededPick 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

Mttao GitHub ↗

Exploring technology and life's wisdom

Related Posts

View all →
  1. 01 NestJS vs Next.js: Backend Framework, React Full Stack, or Both? nextjs· Aug 25, 2026
  2. 02 Claude Code vs Cursor: Which AI Coding Environment Fits Daily Web Development? ai· Jul 22, 2026
  3. 03 The Three Pillars of Modern Distributed Communication: An Architectural Analysis of HTTP, WebSocket, and gRPC frontend· Nov 30, 2025

/ Comments