Loading…
Loading…
A complete tutorial on building a production-ready REST API with NestJS, PostgreSQL, and Drizzle ORM — covering CRUD, validation, error handling, and testing.
Tags
In this tutorial, you will build a complete REST API using NestJS, PostgreSQL, and Drizzle ORM. The API manages a collection of articles with full CRUD operations, request validation, structured error handling, and automated tests. By the end, you will have a production-ready backend that follows NestJS best practices for modularity, type safety, and testability.
Set up a NestJS project with Drizzle ORM connected to PostgreSQL. Define your schema in TypeScript, generate migrations, and build CRUD endpoints with DTO validation using class-validator. Add global exception filters for consistent error responses and write unit tests for your services and controllers.
Use the NestJS CLI to scaffold a new project:
npm i -g @nestjs/cli
nest new articles-api
cd articles-apiChoose npm or yarn when prompted. The CLI generates a project with a modular structure: app.module.ts as the root module, app.controller.ts for routes, and app.service.ts for business logic.
Install the dependencies for Drizzle ORM and PostgreSQL:
npm install drizzle-orm postgres
npm install -D drizzle-kit @types/nodeThe postgres package is the PostgreSQL driver. drizzle-orm is the ORM itself, and drizzle-kit provides CLI tools for migrations.
Create a database configuration module that provides the Drizzle instance to the rest of the application.
First, add your database URL to .env:
DATABASE_URL=postgresql://user:password@localhost:5432/articles_apiInstall the config module:
npm install @nestjs/configCreate the database module:
// src/database/database.module.ts
import { Module, Global } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
export const DATABASE_CONNECTION = "DATABASE_CONNECTION";
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService) => {
const client = postgres(configService.get<string>("DATABASE_URL")!);
return drizzle(client, { schema });
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule {}The @Global() decorator makes the database connection available to all modules without importing DatabaseModule everywhere. The factory pattern lets us inject the ConfigService to read the database URL from environment variables.
Create the schema file that defines your tables in TypeScript:
// src/database/schema.ts
import {
pgTable,
serial,
varchar,
text,
timestamp,
boolean,
} from "drizzle-orm/pg-core";
export const articles = pgTable("articles", {
id: serial("id").primaryKey(),
title: varchar("title", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull().unique(),
content: text("content").notNull(),
excerpt: varchar("excerpt", { length: 500 }),
published: boolean("published").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export type Article = typeof articles.$inferSelect;
export type NewArticle = typeof articles.$inferInsert;The schema uses Drizzle's table builder functions to define columns with their types and constraints. The $inferSelect and $inferInsert utility types extract TypeScript types from the schema, giving you type-safe objects for reading and writing data.
Create a Drizzle config file for migrations:
// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/database/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});Generate and run the initial migration:
npx drizzle-kit generate
npx drizzle-kit pushNestJS organizes code into modules. Generate the articles module with its controller and service:
nest generate module articles
nest generate controller articles
nest generate service articlesThis creates three files in src/articles/. The module ties together the controller and service, the controller handles HTTP requests, and the service contains the business logic.
DTOs (Data Transfer Objects) define the shape of incoming request data and validate it automatically. Install the validation packages:
npm install class-validator class-transformerEnable global validation in main.ts:
// src/main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
})
);
await app.listen(3000);
}
bootstrap();The whitelist option strips properties not defined in the DTO. The forbidNonWhitelisted option returns an error if unexpected properties are sent. The transform option automatically converts plain objects to DTO class instances.
Create the DTOs:
// src/articles/dto/create-article.dto.ts
import {
IsString,
IsOptional,
IsBoolean,
MinLength,
MaxLength,
} from "class-validator";
export class CreateArticleDto {
@IsString()
@MinLength(3)
@MaxLength(255)
title: string;
@IsString()
@MinLength(3)
@MaxLength(255)
slug: string;
@IsString()
@MinLength(10)
content: string;
@IsString()
@IsOptional()
@MaxLength(500)
excerpt?: string;
@IsBoolean()
@IsOptional()
published?: boolean;
}// src/articles/dto/update-article.dto.ts
import { PartialType } from "@nestjs/mapped-types";
import { CreateArticleDto } from "./create-article.dto";
export class UpdateArticleDto extends PartialType(CreateArticleDto) {}The PartialType utility makes all properties optional, which is exactly what you need for PATCH updates. It preserves all the validation decorators from CreateArticleDto but applies them only when the property is present.
The service contains all database operations:
// src/articles/articles.service.ts
import {
Injectable,
Inject,
NotFoundException,
ConflictException,
} from "@nestjs/common";
import { eq } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { DATABASE_CONNECTION } from "../database/database.module";
import * as schema from "../database/schema";
import { articles } from "../database/schema";
import { CreateArticleDto } from "./dto/create-article.dto";
import { UpdateArticleDto } from "./dto/update-article.dto";
@Injectable()
export class ArticlesService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: PostgresJsDatabase<typeof schema>
) {}
async findAll() {
return this.db
.select()
.from(articles)
.orderBy(articles.createdAt);
}
async findOne(id: number) {
const [article] = await this.db
.select()
.from(articles)
.where(eq(articles.id, id));
if (!article) {
throw new NotFoundException(`Article with ID ${id} not found`);
}
return article;
}
async findBySlug(slug: string) {
const [article] = await this.db
.select()
.from(articles)
.where(eq(articles.slug, slug));
if (!article) {
throw new NotFoundException(`Article with slug "${slug}" not found`);
}
return article;
}
async create(createArticleDto: CreateArticleDto) {
const existing = await this.db
.select()
.from(articles)
.where(eq(articles.slug, createArticleDto.slug));
if (existing.length > 0) {
throw new ConflictException(
`Article with slug "${createArticleDto.slug}" already exists`
);
}
const [article] = await this.db
.insert(articles)
.values(createArticleDto)
.returning();
return article;
}
async update(id: number, updateArticleDto: UpdateArticleDto) {
await this.findOne(id); // Throws NotFoundException if not found
const [updated] = await this.db
.update(articles)
.set({ ...updateArticleDto, updatedAt: new Date() })
.where(eq(articles.id, id))
.returning();
return updated;
}
async remove(id: number) {
await this.findOne(id); // Throws NotFoundException if not found
await this.db.delete(articles).where(eq(articles.id, id));
return { deleted: true };
}
}Each method uses Drizzle's query builder, which provides full type safety. The returning() method returns the inserted or updated row, eliminating the need for a separate SELECT query. NestJS's built-in exception classes like NotFoundException and ConflictException automatically map to the correct HTTP status codes.
The controller maps HTTP endpoints to service methods:
// src/articles/articles.controller.ts
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
ParseIntPipe,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { ArticlesService } from "./articles.service";
import { CreateArticleDto } from "./dto/create-article.dto";
import { UpdateArticleDto } from "./dto/update-article.dto";
@Controller("articles")
export class ArticlesController {
constructor(private readonly articlesService: ArticlesService) {}
@Get()
findAll() {
return this.articlesService.findAll();
}
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
return this.articlesService.findOne(id);
}
@Get("slug/:slug")
findBySlug(@Param("slug") slug: string) {
return this.articlesService.findBySlug(slug);
}
@Post()
@HttpCode(HttpStatus.CREATED)
create(@Body() createArticleDto: CreateArticleDto) {
return this.articlesService.create(createArticleDto);
}
@Patch(":id")
update(
@Param("id", ParseIntPipe) id: number,
@Body() updateArticleDto: UpdateArticleDto
) {
return this.articlesService.update(id, updateArticleDto);
}
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param("id", ParseIntPipe) id: number) {
return this.articlesService.remove(id);
}
}The ParseIntPipe automatically converts the string id parameter to a number and returns a 400 error if it cannot be parsed. The @HttpCode decorator overrides the default status code: 201 for created resources and 204 for successful deletions.
Update the articles module and the app module:
// src/articles/articles.module.ts
import { Module } from "@nestjs/common";
import { ArticlesController } from "./articles.controller";
import { ArticlesService } from "./articles.service";
@Module({
controllers: [ArticlesController],
providers: [ArticlesService],
exports: [ArticlesService],
})
export class ArticlesModule {}// src/app.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { DatabaseModule } from "./database/database.module";
import { ArticlesModule } from "./articles/articles.module";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule,
ArticlesModule,
],
})
export class AppModule {}Create a global exception filter for consistent error responses:
// src/common/filters/http-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from "@nestjs/common";
import { Response } from "express";
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message = "Internal server error";
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
message =
typeof exceptionResponse === "string"
? exceptionResponse
: (exceptionResponse as Record<string, unknown>).message as string;
}
response.status(status).json({
statusCode: status,
message,
timestamp: new Date().toISOString(),
});
}
}Register it globally in main.ts:
import { AllExceptionsFilter } from "./common/filters/http-exception.filter";
app.useGlobalFilters(new AllExceptionsFilter());This ensures every error response follows the same shape, making it predictable for API consumers.
NestJS has built-in testing support. Here is a unit test for the articles service:
// src/articles/articles.service.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { ArticlesService } from "./articles.service";
import { DATABASE_CONNECTION } from "../database/database.module";
import { NotFoundException, ConflictException } from "@nestjs/common";
describe("ArticlesService", () => {
let service: ArticlesService;
let mockDb: Record<string, jest.Mock>;
beforeEach(async () => {
mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockResolvedValue([]),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([
{
id: 1,
title: "Test Article",
slug: "test-article",
content: "Test content for the article.",
published: false,
createdAt: new Date(),
updatedAt: new Date(),
},
]),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ArticlesService,
{
provide: DATABASE_CONNECTION,
useValue: mockDb,
},
],
}).compile();
service = module.get<ArticlesService>(ArticlesService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("findAll", () => {
it("should return an array of articles", async () => {
const result = await service.findAll();
expect(result).toEqual([]);
expect(mockDb.select).toHaveBeenCalled();
});
});
describe("create", () => {
it("should create and return an article", async () => {
mockDb.where = jest.fn().mockResolvedValue([]);
const dto = {
title: "Test Article",
slug: "test-article",
content: "Test content for the article.",
};
const result = await service.create(dto);
expect(result.title).toBe("Test Article");
expect(mockDb.insert).toHaveBeenCalled();
});
});
});And a controller test:
// src/articles/articles.controller.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
import { ArticlesController } from "./articles.controller";
import { ArticlesService } from "./articles.service";
describe("ArticlesController", () => {
let controller: ArticlesController;
let service: ArticlesService;
const mockArticle = {
id: 1,
title: "Test Article",
slug: "test-article",
content: "Test content",
excerpt: null,
published: false,
createdAt: new Date(),
updatedAt: new Date(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ArticlesController],
providers: [
{
provide: ArticlesService,
useValue: {
findAll: jest.fn().mockResolvedValue([mockArticle]),
findOne: jest.fn().mockResolvedValue(mockArticle),
create: jest.fn().mockResolvedValue(mockArticle),
update: jest.fn().mockResolvedValue(mockArticle),
remove: jest.fn().mockResolvedValue({ deleted: true }),
},
},
],
}).compile();
controller = module.get<ArticlesController>(ArticlesController);
service = module.get<ArticlesService>(ArticlesService);
});
it("should return all articles", async () => {
const result = await controller.findAll();
expect(result).toEqual([mockArticle]);
expect(service.findAll).toHaveBeenCalled();
});
it("should return a single article", async () => {
const result = await controller.findOne(1);
expect(result).toEqual(mockArticle);
expect(service.findOne).toHaveBeenCalledWith(1);
});
it("should create an article", async () => {
const dto = {
title: "Test Article",
slug: "test-article",
content: "Test content",
};
const result = await controller.create(dto);
expect(result).toEqual(mockArticle);
});
});Run the tests:
npm run testHere is a summary of all the endpoints your API exposes:
| Method | Endpoint | Description |
|---|---|---|
| GET | /articles | List all articles |
| GET | /articles/:id | Get article by ID |
| GET | /articles/slug/:slug | Get article by slug |
| POST | /articles | Create a new article |
| PATCH | /articles/:id | Update an article |
| DELETE | /articles/:id | Delete an article |
page and limit with Drizzle's .limit() and .offset()tsvector and tsquery@nestjs/throttler to prevent API abuse@nestjs/swagger to generate interactive API documentationWhat is NestJS and why use it for a REST API?
NestJS is a progressive Node.js framework for building server-side applications using TypeScript. It uses decorators and dependency injection inspired by Angular, providing a structured architecture with modules, controllers, and services. This structure makes it easier to organize code, write tests, and scale applications compared to minimal frameworks like Express.
Why use Drizzle ORM instead of Prisma with NestJS?
Drizzle ORM provides a SQL-like query builder with full TypeScript type inference from your schema. Unlike Prisma, Drizzle has no binary engine dependency, generates types from TypeScript rather than a separate schema file, and produces queries that map directly to the SQL you would write by hand. This makes it faster in serverless environments and easier to reason about query performance.
How does validation work in NestJS?
NestJS uses the class-validator and class-transformer libraries with a global ValidationPipe. You define DTO classes with validation decorators like @IsString(), @IsEmail(), and @MinLength(). The ValidationPipe automatically validates incoming request bodies against these DTOs and returns detailed error messages for invalid data.
How do you handle errors in a NestJS API?
NestJS provides built-in exception classes like NotFoundException, BadRequestException, and ConflictException that automatically map to HTTP status codes. For custom error handling, you can create exception filters that catch specific exceptions and format the response. The framework also supports global error handling that catches unhandled exceptions.
How do you test a NestJS REST API?
NestJS has built-in support for unit and integration testing using Jest. The testing module lets you create isolated module instances with mocked dependencies. For unit tests, you mock the service layer and test controllers in isolation. For integration tests, you use supertest with the full NestJS application to test HTTP endpoints end-to-end.
Collaboration
I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI: let's bring your vision to life.
Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.
A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.
A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.