DEV Community

Nabin Kandel
Nabin Kandel

Posted on

Build a Tiny NestJS Todo App: The Minimal MVC Approach

NestJS is often praised for its scalability and enterprise-grade architecture, but that can sometimes make it feel intimidating for beginners. You might hear terms like "Dependency Injection," "Modules," and "Decorators" and wonder if you need a complex database setup just to get started.

You don't.

In this tutorial, we're going to strip away the complexity and build a tiny, in-memory Todo API using NestJS. We'll stick to a minimal MVC (Model-View-Controller) style patternβ€”though since this is an API, think of it as DTO-Service-Controller. No database, no ORM, just pure TypeScript logic.

By the end of this guide, you'll understand how data flows through a NestJS application.

πŸ“ The Project Structure

We are keeping things flat and simple. Here is the file structure we will be working with:

src/
β”œβ”€β”€ todo/
β”‚   β”œβ”€β”€ dto/
β”‚   β”‚   └── create-todo.dto.ts
β”‚   β”œβ”€β”€ todo.controller.ts
β”‚   └── todo.service.ts
β”œβ”€β”€ app.module.ts
└── main.ts
Enter fullscreen mode Exit fullscreen mode

Step 1: Define the Data Shape (DTO)

In NestJS, it's best practice to define what your data looks like using a DTO (Data Transfer Object). This ensures that when a user sends data to our API, we know exactly what fields to expect.

Create src/todo/dto/create-todo.dto.ts:

export class CreateTodoDto {
  title: string;
  description?: string;
}
Enter fullscreen mode Exit fullscreen mode

Note: In a production app, you might use class-validator to enforce rules (like making title required), but for this tiny app, a simple class definition is enough to define the type.

Step 2: The Service (Business Logic)

The Service is where the magic happens. It holds our data and performs operations on it. Since we aren't using a database, we'll store our todos in a simple array in memory.

Create src/todo/todo.service.ts:

import { Injectable } from '@nestjs/common';
import { CreateTodoDto } from './dto/create-todo.dto';

export interface Todo {
  id: number;
  title: string;
  description?: string;
}

@Injectable()
export class TodoService {
  private todos: Todo[] = [];
  private idCounter = 1;

  findAll(): Todo[] {
    return this.todos;
  }

  create(createTodoDto: CreateTodoDto): Todo {
    const newTodo: Todo = {
      id: this.idCounter++,
      ...createTodoDto,
    };
    this.todos.push(newTodo);
    return newTodo;
  }

  delete(id: number): boolean {
    const initialLength = this.todos.length;
    this.todos = this.todos.filter(todo => todo.id !== id);
    return this.todos.length < initialLength;
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Concept: The @Injectable() decorator tells NestJS that this class can be managed by the dependency injection system. This allows us to inject this service into our controller later.

Step 3: The Controller (Routes)

The Controller listens for incoming HTTP requests (GET, POST, DELETE) and delegates the work to the Service. It doesn't care how the data is saved; it just cares about receiving the request and sending back a response.

Create src/todo/todo.controller.ts:

import { Controller, Get, Post, Delete, Body, Param, HttpCode, HttpStatus } from '@nestjs/common';
import { TodoService } from './todo.service';
import type { Todo } from './todo.service';
import { CreateTodoDto } from './dto/create-todo.dto';

@Controller('todos')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}

  @Get()
  findAll(): Todo[] {
    return this.todoService.findAll();
  }

  @Post()
  create(@Body() createTodoDto: CreateTodoDto): Todo {
    return this.todoService.create(createTodoDto);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.OK)
  delete(@Param('id') id: string): { message: string } {
    const deleted = this.todoService.delete(parseInt(id, 10));
    if (!deleted) {
      return { message: 'Todo not found' };
    }
    return { message: 'Todo deleted successfully' };
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Concepts:

  • @Controller('todos'): Sets the base route for all endpoints in this class to /todos
  • @Get(), @Post(), @Delete(): Decorators that map methods to specific HTTP verbs
  • @Body(): Extracts the JSON payload from the request
  • @Param('id'): Extracts the id parameter from the URL (e.g., /todos/1)

Step 4: The Module

NestJS applications are organized into modules. The module acts as a container that tells NestJS which controllers and services belong together.

Update src/app.module.ts:

import { Module } from '@nestjs/common';
import { TodoController } from './todo/todo.controller';
import { TodoService } from './todo/todo.service';

@Module({
  controllers: [TodoController],
  providers: [TodoService],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Since this is a tiny app, we are putting everything directly into the root AppModule. In larger apps, you would create a separate TodoModule and import it here.

Step 5: The Entry Point

Finally, we need to bootstrap the application. This is the standard boilerplate code generated by the Nest CLI.

Update src/main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
  console.log('πŸš€ Application is running on: http://localhost:3000');
}
bootstrap();
Enter fullscreen mode Exit fullscreen mode

πŸ§ͺ Let's Test It!

Start your development server by running:

npm run start:dev
Enter fullscreen mode Exit fullscreen mode

Now, let's interact with our API using cURL, Postman, or your browser.

1. βž• Create a Todo

Send a POST request to http://localhost:3000/todos with a JSON body:

{
  "title": "Learn NestJS",
  "description": "Build a simple Todo app"
}
Enter fullscreen mode Exit fullscreen mode

2. πŸ“‹ Get All Todos

Send a GET request to http://localhost:3000/todos

You should see:

[
  {
    "id": 1,
    "title": "Learn NestJS",
    "description": "Build a simple Todo app"
  }
]
Enter fullscreen mode Exit fullscreen mode

3. ❌ Delete a Todo

Send a DELETE request to http://localhost:3000/todos/1

You should receive:

{
  "message": "Todo deleted successfully"
}
Enter fullscreen mode Exit fullscreen mode

If you GET the todos again, the list will be empty!

πŸŽ‰ Conclusion

You've just built a functional REST API with NestJS!

This minimal example demonstrates the core architectural pattern of NestJS:

  1. DTOs define the data shape.
  2. Services handle business logic and data.
  3. Controllers handle HTTP requests.
  4. Modules wire everything together.

Enjoy your clean, tiny NestJS Todo app! ✨


Made with ❀️ for beginners

Top comments (0)