Scalable REST API Architecture with NestJS, Prisma, Swagger, & Docker: How To.

Jide Abdul-QudusJide Abdul-Qudus
13 min read

Introduction

In today's rapidly evolving tech landscape, building robust, scalable, and maintainable backend services is a critical skill for developers. This article explores how to leverage modern technologies like NestJS, Docker, Swagger, and Prisma to create a production-ready REST API. You will learn the core fundamentals of NestJS and best practices while referencing code from this repository.

Modern applications require architectures that facilitate easy maintenance, testing, and scaling. NestJS, a progressive Node.js framework, provides an excellent foundation with its modular, TypeScript-based approach. Combined with Prisma for database operations, Swagger for API documentation, and Docker for containerization, you have a powerful stack for building server-side applications.

To follow step-by-step you can refer to the fully functional application available in this repository.

git clone https://github.com/jideabdqudus/task-manager-api.git

Stack Overview

  1. NestJS

    NestJS is a progressive Node.js framework inspired by Angular's architecture. It uses TypeScript and follows object-oriented programming principles, making it ideal for building scalable server-side applications. Its modular structure encourages the separation of concerns, leading to more maintainable code. (Check out Nest)

  2. Prisma

    Prisma is a next-generation ORM (Object-Relational Mapping) that significantly simplifies database access. With its type-safe database client and schema-based approach, Prisma ensures that database operations are both secure and predictable. (Check out Prisma)

  3. Swagger

    Swagger (OpenAPI) is a powerful tool for documenting and testing APIs. It provides interactive documentation that makes it easier for front-end developers and API consumers to understand and use your endpoints. (Check out Swagger)

  4. Docker

    Docker enables consistent application deployment across different environments through containerization. By packaging your application and its dependencies into containers, you ensure that it runs the same way in development, testing, and production. (Check out Docker)

Architecture

Whether you’re new to NestJS or looking to expand your skills, this article provides step-by-step instructions to build a full-featured API.

The task management API follows a modular architecture:

src/
├── auth/             # Authentication module
├── tasks/            # Task management module
├── user/             # User management module
├── database/         # Database module with Prisma service
├── app.module.ts     # Main application module
└── main.ts           # Application entry point

This structure separates the application into domain-specific modules, each handling specific business logic. Each module contains controllers (handling HTTP requests), services/providers (implementing business logic), and DTOs (Data Transfer Objects).

The final result of your application should look like this:

Swagger docs showing the endpoints.

Prerequisites

This tutorial is designed to be beginner-friendly, but it would be helpful if you’re familiar with these:

  • Basic NestJS

  • TypeScript

  • Docker

Make sure you have the following installed before you begin:

  • Node.js (v16 or later)

  • Node Package Manager

  • Docker & Docker Compose

  • PostgreSQL (or your preferred database)

Step 1: Setting Up Your NestJS Project

Create a new NestJS project using the CLI:

npm i -g @nestjs/cli # run if you don't have the NestJS CLI installed already
nest new task-manager-api
cd task-manager-api

This command scaffolds a basic NestJS application with all essential configurations. The CLI will prompt you to choose a package manager (npm or yarn). Select your preference, and NestJS will scaffold a basic project structure for you. This initial structure provides the foundation for the application.

As said earlier, Nest follows a modular architecture inspired by Angular. The file structure is of format:

  • Modules: Organize application components into logical units

  • Controllers: Handle HTTP requests and return responses

  • Services: Contain business logic used by controllers

  • Providers: Injectable components that can be shared across the application

The src directory is where the essential bits of the application would be and the majority of the codebase. On initializing the application, you’d find some key files in there.

  • src/main.ts: Entry point that bootstraps the application

  • src/app.module.ts: Root module that imports and organizes all other modules

  • src/app.controller.ts: Basic controller handling routes/endpoints

  • src/app.service.ts: Contains business logic used by the controller

Feel free to remove the app.controller.spec.ts, ./app.service, and ./app.controller files to emulate a fresh codebase.

To start the app, you can run npm run start:dev

⭐️ View Codebase


Step 2: Setting Up the Database with Prisma

Prisma provides a clean, type-safe interface to the PostgreSQL database.

  1. Install Prisma:

     npm install @prisma/client
     npm install --save-dev prisma
    
  2. Initialize Prisma:

     npx prisma init
    

    This creates a prisma/ directory with a schema.prisma file. Update the file to define models for User and Task.

     // prisma/schema.prisma
     generator client {
       provider = "prisma-client-js"
     }
    
     datasource db {
       provider = "postgresql"
       url      = env("DATABASE_URL")
     }
    
     model User {
       id        Int     @id @default(autoincrement())
       email     String  @unique
       password  String
       name      String
       tasks Task[]
       createdAt DateTime @default(now())
       updatedAt DateTime @updatedAt
     }
    
     model Task {
       id          Int      @id @default(autoincrement())
       title       String
       description String?
       status      Status   @default(PENDING)
       priority    Priority @default(MEDIUM)
       dueDate     DateTime?
       category    String?
       labels      String?
       ownerId     Int
       owner       User     @relation(fields: [ownerId], references: [id])
       createdAt   DateTime @default(now())
       updatedAt   DateTime @updatedAt
     }
     enum Status {
       PENDING
       IN_PROGRESS
       COMPLETED
     }
    
     enum Priority {
       LOW
       MEDIUM
       HIGH
     }
    
  3. Migrate the Database:

    Set your DATABASE_URL in a .env file and run:

     npx prisma migrate dev --name init
    

    To find out how to create your Database URL, there are helpful guides based on the service you’d like to use (Supabase, Neon etc.)

This schema defines the data models along with their relationships. Prisma generates a type-safe client from this schema, providing methods for CRUD operations with full TypeScript support.

Based on your chosen service, you should see the tables after migration.

Supabase Database Table

⭐️ View Codebase


Step 3: Create Database Service

Let’s create a Database service to handle the Primsa operations. It makes it easy to interact with the database through the application.

You can create services, modules, and controllers directly through your CLI

nest g module     <name>  # generates a module only
nest g service    <name>  # generates a service only  
nest g controller <name>  # generates a controller only
nest g resource   <name>  # generates all 3 of the above

You can, therefore, generate the database service and module (no controller).

// src/database.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class DatabaseService
  extends PrismaClient
  implements OnModuleInit, OnModuleDestroy
{
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}
// src/database.module.ts
import { Module, Global } from '@nestjs/common';
import { DatabaseService } from './database.service';

@Global()
@Module({
  providers: [DatabaseService],
  exports: [DatabaseService],
})
export class DatabaseModule {}

Ordinarily, the newly created services and modules get imported into the app.module.ts file. So ensure they are found there.

⭐️ View Codebase


Step 4: Configure Swagger

The application uses Swagger to automatically generate interactive API documentation. You can configure Swagger in the main.ts file.

  1. Install Swagger:

     npm install --save @nestjs/swagger swagger-ui-express
    
  2. Update main.ts file:

     import { NestFactory } from '@nestjs/core';
     import { AppModule } from './app.module';
     import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
    
     async function bootstrap() {
       const app = await NestFactory.create(AppModule);
       const config = new DocumentBuilder()
         .setTitle('Tasks API')
         .setDescription('Task Management API')
         .addBearerAuth({ // Validation for Protected Routes
           type: 'http',
           name: 'JWT',
           scheme: 'bearer',
           bearerFormat: 'JWT',
         })
         .setVersion('1.0')
         .addTag('tasks')
         .build();
       const document = SwaggerModule.createDocument(app, config);
       SwaggerModule.setup('api-docs', app, document);
       await app.listen(process.env.PORT ?? 3000);
     }
     bootstrap();
    

    This setup creates an interactive documentation interface at the /api-docs endpoint, where developers can explore and test the API.

    ⭐️ View Codebase


Step 5: RESTful Endpoints

Before advancing into this section, it’s important you follow with the codebase at this point as this would be an overview of what to expect.

In NestJS, when building a feature like task-management, you'll typically work with four main components: Modules, Controllers, Services, and DTOs. Let's dive deep into how these components interact and the development flow for creating a complete resource.

  1. Modules

    In NestJS, modules are the organizational units that encapsulate related functionality. They help maintain a clean, organized codebase as applications grow in complexity. Each feature (like Tasks) typically has its own module.

    Here's the possible structure of the TaskModule:

     // src/tasks.module.ts
     import { Module } from '@nestjs/common';
    
     @Module({
       imports: [DatabaseModule], // Import dependencies from other modules
       controllers: [TasksController], // Register controllers
       providers: [TasksService], // Register services
       exports: [TasksService], // Export services for use in other modules
     })
     export class TaskModule {}
    

    This decorator-based configuration tells NestJS:

    • Which other modules this module depends on

    • Which controllers handle the HTTP requests

    • Which providers (services) implement the business logic

    • Which providers should be available to other modules

  2. Controllers

    Controllers are responsible for handling incoming HTTP requests and returning responses to the client. They define routes and use decorators to specify HTTP methods (GET, POST, etc.). Controllers depend on services to perform the actual business logic.

    Here's your task controller with detailed annotations:

     import {
       Controller,
       Get,
       Post,
       Body,
       Patch,
       Param,
       Delete,
     } from '@nestjs/common';
     import {
       ApiTags,
       ApiOperation,
       ApiResponse,
       ApiBearerAuth,
     } from '@nestjs/swagger';
     import { CreateTaskDto } from './dto/create-task.dto';
     import { UpdateTaskDto } from './dto/update-task.dto';
     import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
    
     @ApiTags('tasks') // Swagger tag for grouping endpoints in documentation
     @Controller('tasks') // Base route prefix (/api/tasks)
     @UseGuards(JwtAuthGuard) // Authentication guard applied to all endpoints
     export class TasksController {
       constructor(private readonly tasksService: TasksService) {} // Dependency injection
    
       @Post() // HTTP POST method for creating resources
       @ApiOperation({ summary: 'Create a new task' }) // Swagger documentation
       @ApiResponse({
         status: 201,
         description: 'The task has been successfully created.',
       })
       @ApiBearerAuth() // Indicates authentication requirement in Swagger
       create(@Request() req, @Body() createTaskDto: CreateTaskDto) {
         // Extract user ID from JWT token payload
         return this.tasksService.create({
           ...createTaskDto,
           ownerId: (req as { user: { userId: number } }).user.userId,
         });
       }
    
       @Get() // HTTP GET method for retrieving resources
       @ApiOperation({ summary: 'Get all tasks' })
       @ApiResponse({
         status: 200,
         description: 'The tasks have been successfully retrieved.',
       })
       @ApiBearerAuth()
       findAll(@Request() req: unknown) {
         // Return only tasks owned by the authenticated user
         return this.tasksService.findAllByUser(
           (req as { user: { userId: number } }).user.userId,
         );
       }
    
       @Get(':id') // Dynamic route parameter
       @ApiOperation({ summary: 'Get a task by id' })
       @ApiResponse({
         status: 200,
         description: 'The task has been successfully retrieved.',
       })
       @ApiBearerAuth()
       findOne(@Request() req: unknown, @Param('id') id: string) {
         // Convert string ID to number with the + operator
         return this.tasksService.findOne(
           +id,
           (req as { user: { userId: number } }).user,
         );
       }
    
       @Patch(':id') // HTTP PATCH for partial updates
       @ApiOperation({ summary: 'Update a task' })
       @ApiResponse({
         status: 200,
         description: 'The task has been successfully updated.',
       })
       @ApiBearerAuth()
       update(
         @Request() req: unknown,
         @Param('id') id: string,
         @Body() updateTaskDto: UpdateTaskDto,
       ) {
         return this.tasksService.update(
           +id,
           updateTaskDto,
           (req as { user: { userId: number } }).user,
         );
       }
    
       @Delete(':id') // HTTP DELETE for removing resources
       @ApiOperation({ summary: 'Delete a task' })
       @ApiResponse({
         status: 200,
         description: 'The task has been successfully deleted.',
       })
       @ApiBearerAuth()
       remove(@Request() req: unknown, @Param('id') id: string) {
         return this.tasksService.remove(
           +id,
           (req as { user: { userId: number } }).user,
         );
       }
     }
    

    Notice how each method in the controller:

    1. Uses specific HTTP method decorators (@Get(), @Post(), etc).

    2. Accepts parameters from various sources (@Body(), @Param(), @Request())

    3. Delegates the actual business logic to the injected service

    4. Includes Swagger documentation for API explorability

  3. Services

    Services implement the business logic and are responsible for data storage and retrieval. They abstract the database operations and provide a clean interface for controllers. In the architecture, services interact with the Prisma client to perform database operations.

    Here's a simplified view of the task service:

     import {
       Injectable,
       NotFoundException,
       ForbiddenException,
     } from '@nestjs/common';
    
     @Injectable() // Makes the service available for dependency injection
     export class TasksService {
       constructor(private databaseService: DatabaseService) {} // Inject the Prisma client service
    
       async create(createTaskDto: CreateTaskDto & { ownerId: number }) {
         return this.databaseService.task.create({
           data: createTaskDto,
         });
       }
    
       async findAllByUser(userId: number) {
         return this.databaseService.task.findMany({
           where: { ownerId: userId },
           orderBy: { updatedAt: 'desc' },
         });
       }
    
       async findOne(id: number, user: { userId: number }) {
         const task = await this.databaseService.task.findUnique({
           where: { id },
         });
    
         if (!task) {
           throw new NotFoundException(`Task with ID ${id} not found`);
         }
    
         // Authorization check
         if (task.ownerId !== user.userId) {
           throw new ForbiddenException('You can only access your own tasks');
         }
    
         return task;
       }
    
       // Update and delete methods follow a similar pattern
     }
    

    The service layer is where business rules, validations, and authorization checks should be implemented. The TasksService ensures that users can only access, modify, or delete their own tasks.

  4. DTOs (Data Transfer Objects)

    DTOs define the shape of data for a specific API operation. They provide type safety and validation through decorators, ensuring that incoming requests conform to expected formats.

    The CreateTaskDto:

     import { ApiProperty } from '@nestjs/swagger';
     import {
       IsNotEmpty,
       IsString,
       IsOptional,
       IsEnum,
       IsISO8601,
     } from 'class-validator';
     import { Status, Priority } from '@prisma/client';
    
     export class CreateTaskDto {
       @ApiProperty() // Swagger documentation
       @IsString() // Validation: must be a string
       @IsNotEmpty() // Validation: cannot be empty
       title: string;
    
       @ApiProperty({ required: false })
       @IsString()
       @IsOptional() // Marks the field as optional
       description?: string;
    
       @ApiProperty({ enum: Status, default: Status.PENDING })
       @IsEnum(Status) // Validation: must be one of the enum values
       @IsOptional()
       status?: Status;
    
       @ApiProperty({ enum: Priority, default: Priority.MEDIUM })
       @IsEnum(Priority)
       @IsOptional()
       priority?: Priority;
    
       @ApiProperty({ required: false })
       @IsDateString() // Validates ISO date string format
       @IsOptional()
       dueDate?: string;
    
       @ApiProperty({ required: false })
       @IsString()
       @IsOptional()
       category?: string;
    
       @ApiProperty({ required: false })
       @IsString()
       @IsOptional()
       labels?: string;
     }
    

    UpdateTaskDto extends from a partial version of CreateTaskDto, making all fields optional for updates. Basically, what this does is take a copy of the CreateTaskDto whilst making its fields optional. You can decide to extend it further depending on the needs of your application:

     export class UpdateTaskDto extends PartialType(CreateTaskDto) {}
    

    ⭐️ View Codebase


Understanding the building blocks of Nest

As you can tell, when developing a new feature or resource in NestJS, a typical workflow would be:

  1. Define the Data Model: Start with the Prisma schema to define the database model for your resource (in our case, the Task model).

  2. Generate DTOs: Create Data Transfer Objects to define the shapes of requests and responses (CreateTaskDto, UpdateTaskDto).

  3. Create the Service: Implement the business logic that interacts with the database via Prisma (TasksService).

  4. Build the Controller: Define the API endpoints that map to service methods (TasksController).

  5. Configure the Module: Wire everything together in a module (TaskModule).

  6. Add Swagger Documentation: Add API documentation using decorators wherever necessary.

  7. Implement Authentication/Authorization: Add guards and strategies for protecting routes.

  8. Write Tests: Create unit and integration tests to verify the functionality.

This modular approach allows developers to work on different parts of the feature independently and promotes code reusability and separation of concerns.


Step 6: Containerization with Docker

Docker ensures that the application runs consistently across environments. Our multi-stage Dockerfile optimizes for both development and production:

# Dockerfile

# Development stage
FROM node:20-alpine AS development
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine AS production
ARG NODE_ENV=production
ENV NODE_ENV=${NODE_ENV}
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . .
COPY --from=development /usr/src/app/dist ./dist
CMD ["node", "dist/main"]

This approach:

  1. Creates a development image with all dependencies for building

  2. Creates a production image with only production dependencies

  3. Copies the built application from the development stage to the production image

Combined with docker-compose, this setup allows for easy orchestration of the API alongside its PostgreSQL database.

Next, you’ll need to create a docker-compose.yml file

touch docker-compose.yml     # creates docker-compose.yml file

You can then add the following code to the created file

version: '3.8'
services:
  postgres:
    image: postgres:13.5
    restart: always
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: nest_task
    volumes:
      - postgres-data:/var/lib/postgresql/data
    ports:
      - '5432:5432'
volumes:
  postgres-data:

To start the container, run docker compose up . Ensure you have docker installed before running this on your machine. By now, you should have the PostgreSQL container running. To stop the container, you can run docker compose down.

⭐️ View Codebase


Conclusion

Building a scalable REST API involves combining the right technologies with solid architectural principles. NestJS provides an excellent foundation with its modular structure and TypeScript support. Prisma simplifies database operations with its type-safe client. Swagger automates API documentation, making it easier for others to use your API. Finally, Docker ensures consistent deployment across environments.

This approach results in an API that is not only powerful and flexible but also maintainable and testable. As your application grows, the modular architecture allows for easy extension without compromising stability.

By following the patterns demonstrated in this project, you can build professional-grade APIs that scale with your business needs.

Don't hesitate to explore the repository further, experiment with the code, and refer to the official NestJS documentation for a comprehensive understanding of the framework's capabilities. Happy coding!

⭐️ View Final Code

👉🏾 Learn more about me

👉🏾 Connect on LinkedIn

👉🏾 Subscribe to my blog

0
Subscribe to my newsletter

Read articles from Jide Abdul-Qudus directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Jide Abdul-Qudus
Jide Abdul-Qudus