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

Table of contents
- Introduction
- Stack Overview
- Architecture
- Prerequisites
- Step 1: Setting Up Your NestJS Project
- Step 2: Setting Up the Database with Prisma
- Step 3: Create Database Service
- Step 4: Configure Swagger
- Step 5: RESTful Endpoints
- Understanding the building blocks of Nest
- Step 6: Containerization with Docker
- Conclusion

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
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)
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)
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)
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:
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
Step 2: Setting Up the Database with Prisma
Prisma provides a clean, type-safe interface to the PostgreSQL database.
Install Prisma:
npm install @prisma/client npm install --save-dev prisma
Initialize Prisma:
npx prisma init
This creates a
prisma/
directory with aschema.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 }
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.
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.
Step 4: Configure Swagger
The application uses Swagger to automatically generate interactive API documentation. You can configure Swagger in the main.ts
file.
Install Swagger:
npm install --save @nestjs/swagger swagger-ui-express
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.
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.
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
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:
Uses specific HTTP method decorators (
@Get()
,@Post()
, etc).Accepts parameters from various sources (
@Body()
,@Param()
,@Request()
)Delegates the actual business logic to the injected service
Includes Swagger documentation for API explorability
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.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) {}
Understanding the building blocks of Nest
As you can tell, when developing a new feature or resource in NestJS, a typical workflow would be:
Define the Data Model: Start with the Prisma schema to define the database model for your resource (in our case, the Task model).
Generate DTOs: Create Data Transfer Objects to define the shapes of requests and responses (CreateTaskDto, UpdateTaskDto).
Create the Service: Implement the business logic that interacts with the database via Prisma (TasksService).
Build the Controller: Define the API endpoints that map to service methods (TasksController).
Configure the Module: Wire everything together in a module (TaskModule).
Add Swagger Documentation: Add API documentation using decorators wherever necessary.
Implement Authentication/Authorization: Add guards and strategies for protecting routes.
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:
Creates a development image with all dependencies for building
Creates a production image with only production dependencies
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
.
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!
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
