Task Management REST API with NestJS
Build a layered REST API with NestJS, Zod validation, and PostgreSQL.
Introduction
30 Second Summary
Every app that stores data needs a clear plan for how requests flow from the outside world into a database and back. Without that plan, code quickly becomes tangled and impossible to change without breaking something else.
In this project, you will build a Task Management REST API using NestJS with a clean three-layer architecture that separates HTTP handling, business logic, and data access into independent, swappable pieces.
What You'll Build
A production-grade REST API with full CRUD operations on tasks, validated with Zod schemas and persisted to PostgreSQL via TypeORM.
By the end of this project, you'll have:
- A running NestJS 11 application with explicit Controller, Service, and Repository layers.
- A custom ZodValidationPipe that validates incoming requests and returns clear error messages.
- A Docker Compose stack running PostgreSQL and pgAdmin 4 for visual data inspection.
- Secret Mission: Add query-based filtering and pagination with Zod-validated query parameters.
Are there any prerequisites?
You should be comfortable with TypeScript basics (types, interfaces, classes) and understand how REST APIs work (HTTP methods, status codes, JSON bodies). Basic familiarity with Docker (running containers, reading a compose file) is helpful but not required.
Scaffold NestJS and Start Docker Compose
Every API needs a foundation. Before you write any business logic, you need a project skeleton, a database to store data, and a bridge connecting the two.
In this step, you will scaffold a NestJS project, spin up PostgreSQL and pgAdmin 4 with Docker Compose, and wire everything together with TypeORM.
In this step, get ready to:
- Scaffold a NestJS 11 project with strict TypeScript mode.
- Spin up PostgreSQL and pgAdmin 4 with Docker Compose.
- Connect NestJS to the database with TypeORM.
Scaffold the NestJS project and verify it runs
The NestJS CLI generates a fully configured project structure with TypeScript, testing, and a dev server out of the box.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the NestJS project by running this command:
nest new task-api --strict -p npm
What does this command do?
- nest new task-api creates a new folder called task-api with the full NestJS starter project inside.
- --strict enables strict TypeScript checks, catching more bugs at compile time.
- -p npm selects npm as the package manager so the CLI skips the interactive prompt.
- Confirm the project was created by running:
ls
You should see a task-api folder listed in the output.
- Move into the project folder by running:
cd task-api
- Start the development server by running:
npm run start:dev
- Open your browser and navigate to http://localhost:3000.
- You should see Hello World! displayed in the browser.
Not seeing Hello World?
- Check that no other process is already using port 3000. If so, stop it first.
- Make sure you ran the command inside the task-api folder, not on your Desktop.
- help me debug why my NestJS dev server isn't starting
- Stop the dev server by pressing **Ctrl+C** in your terminal.
Create the Docker Compose stack
Your API needs a database. Instead of installing PostgreSQL directly on your machine, you will run it in a Docker container alongside pgAdmin 4 for visual inspection.
- Create a file called docker-compose.yml in the root of your task-api folder and paste the following code:
services:
postgres:
image: postgres:17
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: task_api
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4:latest
restart: always
environment:
PGADMIN_DEFAULT_EMAIL: admin@admin.com
PGADMIN_DEFAULT_PASSWORD: admin
PGADMIN_LISTEN_PORT: 5050
ports:
- "5050:5050"
depends_on:
- postgres
volumes:
pgdata:
What does this file do?
- The postgres service runs PostgreSQL 17 with a database called task_api and credentials postgres/postgres.
- The pgadmin service runs pgAdmin 4, a web-based database management tool, on port 5050.
- The pgdata volume persists your database data between container restarts.
- Save docker-compose.yml.
- Start the Docker Compose stack by running:
docker compose up -d
- Wait a few seconds for the containers to start, then open http://localhost:5050 in your browser.
- You should see the pgAdmin 4 login page.
- Sign in with email admin@admin.com and password admin.
pgAdmin not loading?
- Run docker compose ps to check that both containers show a status of "running".
- Make sure Docker Desktop is running. Docker containers require the Docker daemon to be active.
- Check that port 5050 is not already in use by another application on your machine.
- help me debug why my Docker Compose stack isn't starting
✔️ Awesome, I've got everything!
Great. Make sure you have saved docker-compose.yml and confirmed pgAdmin is accessible.
ⓧ I'd like to double check the full code
services:
postgres:
image: postgres:17
restart: always
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: task_api
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
pgadmin:
image: dpage/pgadmin4:latest
restart: always
environment:
PGADMIN_DEFAULT_EMAIL: admin@admin.com
PGADMIN_DEFAULT_PASSWORD: admin
PGADMIN_LISTEN_PORT: 5050
ports:
- "5050:5050"
depends_on:
- postgres
volumes:
pgdata:
Install dependencies and configure TypeORM
With the database running, you need packages that let NestJS talk to PostgreSQL. You also need Zod for validation, which you will use in the next step.
- Install the required packages by running:
npm install zod @nestjs/typeorm typeorm pg
What are these packages?
- zod is a schema validation library you will use to validate incoming request data.
- @nestjs/typeorm is the official NestJS integration for TypeORM, providing module-based configuration.
- typeorm is the ORM itself, mapping TypeScript classes to database tables.
- pg is the PostgreSQL driver that TypeORM uses under the hood.
Next, you need to create a minimal TasksModule placeholder so the app compiles when you wire up the database connection.
- Create a folder called tasks inside your src directory by running:
mkdir src/tasks
- Create a file called tasks.module.ts inside src/tasks/ and paste the following code:
import { Module } from '@nestjs/common';
@Module({})
export class TasksModule {}
Why create an empty module now?
The app.module.ts you are about to write imports TasksModule. Without this file, TypeScript would throw a compile error. You will add real content to this module in later steps.
- Save src/tasks/tasks.module.ts.
- Now open src/app.module.ts and replace its entire contents with the following code:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [
// Connect to the PostgreSQL database running in Docker
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'task_api',
autoLoadEntities: true,
synchronize: true,
}),
TasksModule,
],
})
export class AppModule {}
What does this configuration do?
- TypeOrmModule.forRoot() establishes the database connection for the entire application.
- The host, port, username, password, and database values match what you set in docker-compose.yml.
- autoLoadEntities: true automatically registers any entity you add through feature modules.
- synchronize: true auto-creates database tables from your entity definitions. This is convenient for development but should never be used in production.
Why not synchronize in production?
With synchronize: true, TypeORM modifies your database schema on every app restart to match your entities. In production, this can accidentally drop columns or data. Use migrations instead.
- Save src/app.module.ts.
- Start the dev server again by running:
npm run start:dev
- Check the terminal output. You should see the NestJS application start successfully without any "Unable to connect to the database" errors.
- If the app starts and you see the NestJS startup log with the application listening message, your database connection is working.
Seeing 'Unable to connect to the database'?
- Make sure Docker Compose is still running. Run docker compose ps in a separate terminal to confirm.
- Check that the credentials in app.module.ts match those in docker-compose.yml exactly: username postgres, password postgres, database task_api.
- Verify that port 5432 on your machine is not already in use by a local PostgreSQL installation.
- help me debug why NestJS cannot connect to my PostgreSQL database in Docker
✔️ Awesome, I've got everything!
Great. Double check you have saved both src/tasks/tasks.module.ts and src/app.module.ts.
ⓧ I'd like to double check the full code
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'task_api',
autoLoadEntities: true,
synchronize: true,
}),
TasksModule,
],
})
export class AppModule {}
import { Module } from '@nestjs/common';
@Module({})
export class TasksModule {}
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Your foundation is solid. You have a running NestJS app connected to PostgreSQL, with pgAdmin 4 ready for visual inspection. Next up, you will build the validation layer with Zod schemas and a custom pipe.
Define Zod Schemas and the ZodValidationPipe
Your NestJS app is running and connected to PostgreSQL. Now you need a way to validate incoming request data before it reaches your business logic.
In this step, you will build a custom validation pipe using Zod that plugs into the NestJS request pipeline. You will also define the schemas that describe what valid task data looks like.
In this step, get ready to:
- Create a reusable ZodValidationPipe that catches invalid data and returns clear error messages.
- Define Zod schemas for creating and updating tasks with type-safe DTOs.
- Test the pipe by sending invalid data and confirming a 400 response.
Create the custom ZodValidationPipe
NestJS pipes sit between the incoming request and your route handler. They transform or validate data before it reaches your controller. You will create a custom pipe that takes a Zod schema and uses it to validate any value that passes through.
- Create the folder structure for your pipe by running these commands in your terminal:
mkdir -p src/common/pipes
- Create a new file at src/common/pipes/zod-validation.pipe.ts and add the following code:
import {
PipeTransform,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown, metadata: ArgumentMetadata) {
try {
// Attempt to parse the incoming value against the schema
const parsedValue = this.schema.parse(value);
return parsedValue;
} catch (error) {
// If Zod throws, format the errors into a readable response
if (error instanceof ZodError) {
throw new BadRequestException({
message: 'Validation failed',
errors: error.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
})),
});
}
throw new BadRequestException('Validation failed');
}
}
}
What does this code do?
The ZodValidationPipe implements the NestJS PipeTransform interface. It receives a Zod schema in its constructor and uses that schema to validate any value that flows through the pipe.
- schema.parse(value) validates the incoming data. If it passes, the parsed (and potentially transformed) value continues to your handler.
- If validation fails, Zod throws a ZodError. The pipe catches it and throws a NestJS BadRequestException (HTTP 400) with a formatted list of what went wrong.
- Save zod-validation.pipe.ts.
- Confirm your terminal (running npm run start:dev) shows no compilation errors.
Seeing a compilation error?
- Make sure the file is at src/common/pipes/zod-validation.pipe.ts exactly. A wrong folder name will cause an import failure later.
- Check that you installed zod in the previous step. Run npm list zod to verify it appears in your dependencies.
Help me fix my ZodValidationPipe compilation error
Why Zod instead of class-validator?
The default NestJS validation approach uses class-validator decorators on class-based DTOs. Zod offers a different model: you define schemas as plain objects and infer TypeScript types directly from them.
This means your validation rules and your TypeScript types are always in sync. Change the schema, and the type updates automatically. No class definitions, no decorators, no drift between what you validate and what TypeScript checks at compile time.
Create Zod schemas for tasks
With the pipe ready, you need schemas that describe what valid task data looks like. These schemas define the shape of request bodies for creating and updating tasks.
- Create the DTO folder by running this command:
mkdir -p src/tasks/dto
- Create a new file at src/tasks/dto/task.schema.ts and add the status/priority constants plus the create schema:
import { z } from 'zod';
// Define allowed status values as a const object for type safety
export const TaskStatus = {
OPEN: 'OPEN',
IN_PROGRESS: 'IN_PROGRESS',
DONE: 'DONE',
} as const;
// Define allowed priority values
export const TaskPriority = {
LOW: 'LOW',
MEDIUM: 'MEDIUM',
HIGH: 'HIGH',
} as const;
// Schema for creating a new task
export const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
status: z
.enum([TaskStatus.OPEN, TaskStatus.IN_PROGRESS, TaskStatus.DONE])
.default(TaskStatus.OPEN),
priority: z
.enum([TaskPriority.LOW, TaskPriority.MEDIUM, TaskPriority.HIGH])
.default(TaskPriority.MEDIUM),
dueDate: z.string().datetime().optional(),
});
What does this code do?
- TaskStatus and TaskPriority are const objects that define the allowed values. Using as const makes TypeScript treat them as literal types, not generic strings.
- createTaskSchema requires a title with at least 1 character. All other fields are optional or have sensible defaults (status defaults to OPEN, priority defaults to MEDIUM).
- dueDate uses z.string().datetime() to ensure the value is a valid ISO 8601 datetime string if provided.
- Now add the update schema and type exports below the createTaskSchema in the same file:
// Schema for updating a task - all fields optional but at least one required
export const updateTaskSchema = z
.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
status: z
.enum([TaskStatus.OPEN, TaskStatus.IN_PROGRESS, TaskStatus.DONE])
.optional(),
priority: z
.enum([TaskPriority.LOW, TaskPriority.MEDIUM, TaskPriority.HIGH])
.optional(),
dueDate: z.string().datetime().optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field must be provided',
});
// Infer TypeScript types directly from the schemas
export type CreateTaskDto = z.infer<typeof createTaskSchema>;
export type UpdateTaskDto = z.infer<typeof updateTaskSchema>;
What does this code do?
- updateTaskSchema makes every field optional. The .refine() call adds a custom check: the body must contain at least one field. This prevents empty PATCH requests.
- z.infer<typeof schema> extracts the TypeScript type from each schema. These types (CreateTaskDto and UpdateTaskDto) stay perfectly in sync with your validation rules.
- Save task.schema.ts.
- Check your terminal. The dev server should recompile without errors.
Seeing a type error on the enum values?
- Make sure both TaskStatus and TaskPriority use as const at the end. Without it, TypeScript infers the values as string instead of literal types, which breaks z.enum().
- Verify the import at the top of the file is import { z } from 'zod' with a lowercase z.
Help me fix my Zod schema type errors
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete src/common/pipes/zod-validation.pipe.ts:
import {
PipeTransform,
ArgumentMetadata,
BadRequestException,
} from '@nestjs/common';
import { ZodSchema, ZodError } from 'zod';
export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) {}
transform(value: unknown, metadata: ArgumentMetadata) {
try {
const parsedValue = this.schema.parse(value);
return parsedValue;
} catch (error) {
if (error instanceof ZodError) {
throw new BadRequestException({
message: 'Validation failed',
errors: error.errors.map((e) => ({
path: e.path.join('.'),
message: e.message,
})),
});
}
throw new BadRequestException('Validation failed');
}
}
}
Here is the complete src/tasks/dto/task.schema.ts:
import { z } from 'zod';
export const TaskStatus = {
OPEN: 'OPEN',
IN_PROGRESS: 'IN_PROGRESS',
DONE: 'DONE',
} as const;
export const TaskPriority = {
LOW: 'LOW',
MEDIUM: 'MEDIUM',
HIGH: 'HIGH',
} as const;
export const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
status: z
.enum([TaskStatus.OPEN, TaskStatus.IN_PROGRESS, TaskStatus.DONE])
.default(TaskStatus.OPEN),
priority: z
.enum([TaskPriority.LOW, TaskPriority.MEDIUM, TaskPriority.HIGH])
.default(TaskPriority.MEDIUM),
dueDate: z.string().datetime().optional(),
});
export const updateTaskSchema = z
.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
status: z
.enum([TaskStatus.OPEN, TaskStatus.IN_PROGRESS, TaskStatus.DONE])
.optional(),
priority: z
.enum([TaskPriority.LOW, TaskPriority.MEDIUM, TaskPriority.HIGH])
.optional(),
dueDate: z.string().datetime().optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: 'At least one field must be provided',
});
export type CreateTaskDto = z.infer<typeof createTaskSchema>;
export type UpdateTaskDto = z.infer<typeof updateTaskSchema>;
Test the pipe with a temporary endpoint
Before building the full controller, you will create a quick temporary endpoint to prove the pipe rejects invalid data correctly.
- Create a temporary test controller at src/test-validation.controller.ts with this code:
import { Controller, Post, Body, UsePipes } from '@nestjs/common';
import { ZodValidationPipe } from './common/pipes/zod-validation.pipe';
import { createTaskSchema, CreateTaskDto } from './tasks/dto/task.schema';
// Temporary controller to test the validation pipe
@Controller('test')
export class TestController {
@Post()
@UsePipes(new ZodValidationPipe(createTaskSchema))
testValidation(@Body() dto: CreateTaskDto) {
return { success: true, data: dto };
}
}
- Open src/app.module.ts and temporarily add the TestController to the module. Find the @Module decorator and add a controllers array:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
import { TestController } from './test-validation.controller';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'task_api',
autoLoadEntities: true,
synchronize: true,
}),
TasksModule,
],
controllers: [TestController],
})
export class AppModule {}
- Save both files and wait for the dev server to recompile.
- Open a new terminal tab and send an empty body to test validation by running this command:
curl -X POST http://localhost:3000/test -H "Content-Type: application/json" -d "{}"
- You should see a 400 response with a JSON body containing a "Validation failed" message and an error for the title field saying it is required.
What should I see?
The response should look something like this:
{
"message": "Validation failed",
"errors": [
{
"path": "title",
"message": "Title is required"
}
]
}
This proves your pipe caught the missing title field and returned a clear error message from the Zod schema.
- Now send a valid body to confirm the pipe lets good data through by running:
curl -X POST http://localhost:3000/test -H "Content-Type: application/json" -d "{\"title\": \"My first task\"}"
- You should see a 201 response with success: true and the parsed data including the default status and priority values.
Getting a 404 instead of 400?
- Make sure you added TestController to the controllers array in app.module.ts. Without this, NestJS does not register the route.
- Check that the URL is http://localhost:3000/test (not /tasks) and the method is POST.
- Verify the dev server restarted after saving. Look for the route mapping log in your terminal output.
Help me debug why my test endpoint returns 404
Clean up the test controller
The test controller served its purpose. Remove it so your project stays clean before building the real controller in the next step.
- Delete the file src/test-validation.controller.ts.
- Open src/app.module.ts and remove the TestController import line and the controllers array so it matches the original:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TasksModule } from './tasks/tasks.module';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'task_api',
autoLoadEntities: true,
synchronize: true,
}),
TasksModule,
],
})
export class AppModule {}
- Save app.module.ts and confirm the dev server recompiles without errors.
Your validation layer is ready. You have a reusable pipe that can validate any request data against any Zod schema, plus two schemas that define exactly what valid task data looks like. Next up, you will create the TypeORM entity and repository layer that persists tasks to PostgreSQL.
Create the Task Entity and Repository Layer
Your validation layer is ready to reject bad data. Now you need something for that valid data to map onto in the database.
In this step, you'll define a TypeORM entity that tells PostgreSQL exactly what a "task" looks like as a table row. Then you'll wrap all database operations inside a Repository class that keeps data access isolated from your business logic.
In this step, get ready to:
- Define a TypeORM entity that maps to a PostgreSQL table.
- Create a Repository class that encapsulates all database operations.
- Register everything in the TasksModule so NestJS can wire it up.
Define the TypeORM entity
A TypeORM entity is a TypeScript class decorated with special markers that map class properties to database columns. When synchronize: true is set in your TypeORM config, the framework auto-creates the matching table in PostgreSQL on startup.
- Create a new folder entities inside src/tasks/.
- Create a new file task.entity.ts inside src/tasks/entities/ and add the following code:
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
export class Task {
// Auto-generated UUID serves as the primary key
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
// Nullable because description is optional when creating a task
@Column({ nullable: true })
description: string;
// Default status for every new task
@Column({ default: 'OPEN' })
status: string;
// Default priority for every new task
@Column({ default: 'MEDIUM' })
priority: string;
// Stored as a timestamp, nullable because due dates are optional
@Column({ type: 'timestamp', nullable: true })
dueDate: Date;
// Automatically set when the row is first inserted
@CreateDateColumn()
createdAt: Date;
// Automatically updated whenever the row changes
@UpdateDateColumn()
updatedAt: Date;
}
What does this code do?
The @Entity() decorator marks this class as a database table. TypeORM uses the class name to generate the table name in snake_case, so Task becomes a task table.
Each @Column() decorator maps a property to a column. Options like nullable: true and default: 'OPEN' translate directly to PostgreSQL column constraints.
The @CreateDateColumn() and @UpdateDateColumn() decorators handle timestamps automatically. You never set these manually.
- Save task.entity.ts.
Seeing red squiggly lines in your editor?
Make sure the typeorm package is installed. You installed it in Step 1, but if your editor still shows errors, try restarting the TypeScript language server.
help me fix TypeORM import errors in my entity file
Create the TasksRepository
The Repository layer is the only place in your app that directly touches the database. Every other layer asks the repository for data instead of running queries itself. This means you can swap your entire database later without rewriting your business logic.
- Create a new file tasks.repository.ts inside src/tasks/ and add the following code:
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Task } from './entities/task.entity';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.schema';
@Injectable()
export class TasksRepository {
constructor(
// Inject the TypeORM-managed repository for the Task entity
@InjectRepository(Task)
private readonly repository: Repository<Task>,
) {}
// Insert a new task row into the database
async create(data: CreateTaskDto): Promise<Task> {
const task = this.repository.create(data);
return this.repository.save(task);
}
// Retrieve all tasks, newest first
async findAll(): Promise<Task[]> {
return this.repository.find({ order: { createdAt: 'DESC' } });
}
// Find a single task by its UUID
async findById(id: string): Promise<Task | null> {
return this.repository.findOneBy({ id });
}
// Apply partial updates to a task row
async update(id: string, data: UpdateTaskDto): Promise<Task | null> {
await this.repository.update(id, data);
return this.findById(id);
}
// Delete a task row by its UUID
async remove(id: string): Promise<void> {
await this.repository.delete(id);
}
}
What does this code do?
The @Injectable() decorator tells NestJS this class can be injected as a dependency. The @InjectRepository(Task) decorator gives you TypeORM's built-in repository for the Task entity.
Each method wraps exactly one database operation. create() builds a Task instance from the DTO and saves it. findAll() returns tasks sorted by creation date. findById() returns one task or null. update() applies partial changes and returns the updated row. remove() deletes the row entirely.
This layer has no knowledge of HTTP requests or business rules. It only knows how to talk to PostgreSQL.
- Save tasks.repository.ts.
Getting a 'Cannot find module' error?
Double check that your entity file is at src/tasks/entities/task.entity.ts and the import path in the repository matches: './entities/task.entity'. A misplaced folder or typo in the path causes this.
help me fix module import errors in my tasks repository
Register the entity and repository in TasksModule
NestJS uses modules to organize which classes belong together. You need to tell the TasksModule about your new entity and repository so the Dependency Injection system can wire them up.
- Open src/tasks/tasks.module.ts and replace its contents with the following:
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Task } from './entities/task.entity';
import { TasksRepository } from './tasks.repository';
@Module({
// Register the Task entity so TypeORM creates its repository
imports: [TypeOrmModule.forFeature([Task])],
// Make TasksRepository available for injection within this module
providers: [TasksRepository],
})
export class TasksModule {}
What does this code do?
The TypeOrmModule.forFeature([Task]) call registers the Task entity within this module's scope. This is what makes @InjectRepository(Task) work inside TasksRepository.
Adding TasksRepository to providers tells NestJS that this class is available for dependency injection. Any other class in this module can now receive it through its constructor.
- Save tasks.module.ts.
- Switch back to your terminal running npm run start:dev. The server should recompile with no errors.
- Look for a log line mentioning the database synchronization. Because synchronize: true is set, TypeORM automatically creates the task table in PostgreSQL.
Seeing a 'Nest cannot resolve dependencies' error?
This means TypeOrmModule.forFeature([Task]) is missing from the imports array, or TasksRepository is missing from providers. Check that both are present in your @Module() decorator.
help me resolve NestJS dependency injection errors in my TasksModule
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created or modified in this step.
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity()
export class Task {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
title: string;
@Column({ nullable: true })
description: string;
@Column({ default: 'OPEN' })
status: string;
@Column({ default: 'MEDIUM' })
priority: string;
@Column({ type: 'timestamp', nullable: true })
dueDate: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Task } from './entities/task.entity';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.schema';
@Injectable()
export class TasksRepository {
constructor(
@InjectRepository(Task)
private readonly repository: Repository<Task>,
) {}
async create(data: CreateTaskDto): Promise<Task> {
const task = this.repository.create(data);
return this.repository.save(task);
}
async findAll(): Promise<Task[]> {
return this.repository.find({ order: { createdAt: 'DESC' } });
}
async findById(id: string): Promise<Task | null> {
return this.repository.findOneBy({ id });
}
async update(id: string, data: UpdateTaskDto): Promise<Task | null> {
await this.repository.update(id, data);
return this.findById(id);
}
async remove(id: string): Promise<void> {
await this.repository.delete(id);
}
}
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Task } from './entities/task.entity';
import { TasksRepository } from './tasks.repository';
@Module({
imports: [TypeOrmModule.forFeature([Task])],
providers: [TasksRepository],
})
export class TasksModule {}
Your data layer is locked in. The entity defines the shape, and the repository provides a clean interface for every CRUD operation. Next up, you'll build the Service and Controller layers that bring this API to life for HTTP clients.
Implement Service and Controller Layers
Your Repository layer handles all data access. Now you need two more layers to complete the architecture: a Service that contains business logic, and a Controller that maps HTTP requests to service methods.
The Service layer is the gatekeeper. It decides what happens when a task is not found (throw an error) or when data needs transformation before storage. The Controller simply receives HTTP requests, delegates to the Service, and returns responses.
In this step, get ready to:
- Create the TasksService with business logic and error handling.
- Create the TasksController with five CRUD endpoints.
- Test the full API with curl requests.
Create the TasksService
The Service layer depends on TasksRepository (not on TypeORM directly). It contains all business rules, like throwing a 404 error when a task is not found.
- Create a new file at src/tasks/tasks.service.ts and add the following code:
import { Injectable, NotFoundException } from '@nestjs/common';
import { TasksRepository } from './tasks.repository';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.schema';
import { Task } from './entities/task.entity';
@Injectable()
export class TasksService {
constructor(private readonly tasksRepository: TasksRepository) {}
// Create a new task and return it
async createTask(dto: CreateTaskDto): Promise<Task> {
return this.tasksRepository.create(dto);
}
// Retrieve all tasks from the repository
async getAllTasks(): Promise<Task[]> {
return this.tasksRepository.findAll();
}
// Find a task by ID, throw 404 if not found
async getTaskById(id: string): Promise<Task> {
const task = await this.tasksRepository.findById(id);
if (!task) {
throw new NotFoundException(`Task with id "${id}" not found`);
}
return task;
}
// Update a task, throw 404 if it doesn't exist
async updateTask(id: string, dto: UpdateTaskDto): Promise<Task> {
const task = await this.tasksRepository.findById(id);
if (!task) {
throw new NotFoundException(`Task with id "${id}" not found`);
}
return this.tasksRepository.update(id, dto);
}
// Delete a task, throw 404 if it doesn't exist
async deleteTask(id: string): Promise<void> {
const task = await this.tasksRepository.findById(id);
if (!task) {
throw new NotFoundException(`Task with id "${id}" not found`);
}
await this.tasksRepository.remove(id);
}
}
What does this code do?
The @Injectable() decorator registers this class with NestJS's Dependency Injection system. The constructor receives TasksRepository automatically at runtime.
Each method that looks up a task by ID checks if the result is null. If it is, the service throws a NotFoundException which NestJS automatically converts into a 404 HTTP response. This means the Controller never has to worry about missing-task logic.
- Save src/tasks/tasks.service.ts.
- You won't see a change yet. The dev server is still waiting for the Controller file to exist.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
import { Injectable, NotFoundException } from '@nestjs/common';
import { TasksRepository } from './tasks.repository';
import { CreateTaskDto, UpdateTaskDto } from './dto/task.schema';
import { Task } from './entities/task.entity';
@Injectable()
export class TasksService {
constructor(private readonly tasksRepository: TasksRepository) {}
async createTask(dto: CreateTaskDto): Promise<Task> {
return this.tasksRepository.create(dto);
}
async getAllTasks(): Promise<Task[]> {
return this.tasksRepository.findAll();
}
async getTaskById(id: string): Promise<Task> {
const task = await this.tasksRepository.findById(id);
if (!task) {
throw new NotFoundException(`Task with id "${id}" not found`);
}
return task;
}
async updateTask(id: string, dto: UpdateTaskDto): Promise<Task> {
const task = await this.tasksRepository.findById(id);
if (!task) {
throw new NotFoundException(`Task with id "${id}" not found`);
}
return this.tasksRepository.update(id, dto);
}
async deleteTask(id: string): Promise<void> {
const task = await this.tasksRepository.findById(id);
if (!task) {
throw new NotFoundException(`Task with id "${id}" not found`);
}
await this.tasksRepository.remove(id);
}
}
Why does the Service check for existence before updating or deleting?
TypeORM's update() and delete() methods succeed silently even if the row doesn't exist. Without the existence check, a client could DELETE a non-existent task and get a 200 response. The Service ensures predictable 404 errors for invalid IDs.
Create the TasksController
The Controller is the thinnest layer. It maps HTTP verbs and routes to Service methods, applies validation pipes to incoming data, and returns the result. No business logic lives here.
- Create a new file at src/tasks/tasks.controller.ts and add the following code:
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
ParseUUIDPipe,
} from '@nestjs/common';
import { TasksService } from './tasks.service';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import {
createTaskSchema,
updateTaskSchema,
CreateTaskDto,
UpdateTaskDto,
} from './dto/task.schema';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
// POST /tasks - create a new task with Zod validation
@Post()
create(@Body(new ZodValidationPipe(createTaskSchema)) dto: CreateTaskDto) {
return this.tasksService.createTask(dto);
}
// GET /tasks - retrieve all tasks
@Get()
findAll() {
return this.tasksService.getAllTasks();
}
// GET /tasks/:id - retrieve a single task by UUID
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.tasksService.getTaskById(id);
}
// PATCH /tasks/:id - update a task with Zod validation
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(updateTaskSchema)) dto: UpdateTaskDto,
) {
return this.tasksService.updateTask(id, dto);
}
// DELETE /tasks/:id - remove a task
@Delete(':id')
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.tasksService.deleteTask(id);
}
}
What does this code do?
- @Controller('tasks') sets the route prefix so all endpoints start with /tasks.
- @Body(new ZodValidationPipe(createTaskSchema)) validates the request body against your Zod schema before the method runs. Invalid data never reaches the Service.
- ParseUUIDPipe is a built-in NestJS pipe that rejects any :id parameter that is not a valid UUID. This prevents bad IDs from ever hitting the database.
- Each method delegates to TasksService and returns the result directly. NestJS serializes the return value to JSON automatically.
- Save src/tasks/tasks.controller.ts.
- Check the terminal where npm run start:dev is running. The dev server should restart without errors and show TasksController {/tasks} in the route mapping output.
Seeing a compilation error?
- Check that tasks.service.ts is saved. The controller imports from it, so both files must exist.
- Verify your tasks.module.ts has TasksService in the providers array and TasksController in the controllers array. This was set up in the previous step.
- Make sure the import path for ZodValidationPipe uses '../common/pipes/zod-validation.pipe' (one directory up from tasks/).
Still stuck? Help me debug my NestJS controller compilation error.
✔️ Awesome, I've got everything!
Great. Make sure both new files are saved before moving on to testing.
ⓧ I'd like to double check the full code
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
ParseUUIDPipe,
} from '@nestjs/common';
import { TasksService } from './tasks.service';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import {
createTaskSchema,
updateTaskSchema,
CreateTaskDto,
UpdateTaskDto,
} from './dto/task.schema';
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Post()
create(@Body(new ZodValidationPipe(createTaskSchema)) dto: CreateTaskDto) {
return this.tasksService.createTask(dto);
}
@Get()
findAll() {
return this.tasksService.getAllTasks();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.tasksService.getTaskById(id);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(updateTaskSchema)) dto: UpdateTaskDto,
) {
return this.tasksService.updateTask(id, dto);
}
@Delete(':id')
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.tasksService.deleteTask(id);
}
}
Test the full CRUD API
Your tasks.module.ts already wires everything together from the previous step. With both the Service and Controller files now in place, the full API is live. Time to test all five endpoints.
- Create a task by running this command in a new terminal:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Build REST API", "description": "Finish the task management project", "priority": "HIGH"}'
You should see a JSON response containing your new task with an id, createdAt, and updatedAt timestamp. The status defaults to OPEN because you did not specify one.
- Retrieve all tasks by running:
curl http://localhost:3000/tasks
You should see an array containing the task you just created.
- Test validation by sending an empty body:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{}'
You should see a 400 response with "Validation failed" and an error array showing that title is required. This confirms your ZodValidationPipe is working.
What about the other endpoints?
You have five endpoints total: POST, GET (all), GET (by id), PATCH, and DELETE. To test the single-task endpoints, copy the id from your POST response and use it in GET, PATCH, and DELETE requests. You will do a thorough end-to-end test in the next step.
Getting connection refused?
- Make sure npm run start:dev is still running in your other terminal.
- Confirm the Docker Compose stack is up (docker compose ps should show postgres as running). The API needs PostgreSQL to save tasks.
- If you see ECONNREFUSED in the NestJS logs, restart Docker Compose with docker compose up -d and then restart the NestJS dev server.
Need more help? Help me debug a connection error between my NestJS app and the Docker PostgreSQL database.
Your full CRUD API is live and accepting requests. Next up, you will visually verify all of this data using pgAdmin to inspect the PostgreSQL database directly.
Verify End-to-End with pgAdmin
You have a fully functional 3-layer REST API with Controllers, Services, and a Repository all wired together. The last thing to prove is that data actually flows from your HTTP requests all the way down to PostgreSQL and back.
In this step, you will exercise every CRUD endpoint, then open pgAdmin 4 to visually confirm rows in the database. This closes the loop between your API layer and the persistence layer.
In this step, get ready to:
- Create tasks with different statuses and priorities via POST requests.
- Connect pgAdmin 4 to PostgreSQL and verify stored rows.
- Test the full CRUD lifecycle including validation errors.
Create tasks via POST requests
To verify the full stack, you need real data in the database. You will create several tasks with varying statuses and priorities using curl commands.
- Open a new terminal window (keep your NestJS dev server running in the existing one).
- Create your first task by running this command:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Design database schema", "description": "Plan the tables and relationships", "priority": "HIGH"}'
- Create a second task with a different status by running:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write unit tests", "status": "IN_PROGRESS", "priority": "MEDIUM"}'
- Create a third task by running:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Deploy to staging", "description": "Push to the staging environment", "status": "OPEN", "priority": "LOW"}'
What are these commands doing?
Each curl command sends a POST request to your /tasks endpoint with a JSON body. The ZodValidationPipe validates each body against createTaskSchema, then the Controller passes the validated DTO to the Service, which calls the Repository to persist it in PostgreSQL.
Notice that you only provided title in the second task. The Zod schema applies defaults for status and priority when you override them, but leaves other optional fields like description as null.
- Confirm all tasks were stored by running:
curl http://localhost:3000/tasks
You should see a JSON array containing all three tasks, each with a generated id, createdAt, and updatedAt timestamp.
Copy a task ID for later
Pick one task id from the response and keep it handy. You will use it in the PATCH and DELETE tests later in this step.
Getting connection refused?
Make sure your NestJS dev server is still running in the other terminal window. If you stopped it, restart with npm run start:dev from inside the task-api folder.
Also verify Docker Compose is running. Run docker compose ps and confirm both postgres and pgadmin containers show a status of Up.
Help me troubleshoot why my curl POST to localhost:3000/tasks is being refused
Connect pgAdmin 4 to PostgreSQL
Your tasks exist in the API responses, but you can also see them directly in the database. pgAdmin gives you a visual interface to browse PostgreSQL tables and rows.
- Open your browser and navigate to http://localhost:5050.
- Log in with email admin@admin.com and password admin (these are the credentials from your docker-compose.yml).
- In the left sidebar, right-click Servers and select Register then Server.
- In the General tab, enter Task API in the Name field.
- Click the Connection tab.
- Enter postgres in the Host name/address field (this is the Docker service name that pgAdmin uses to reach the PostgreSQL container).
- Leave Port as 5432.
- Enter task_api in the Maintenance database field.
- Enter postgres in the Username field.
- Enter postgres in the Password field.
- Click Save.
Why use postgres as the host?
Both the pgAdmin container and the PostgreSQL container run on the same Docker Compose network. Inside that network, containers reach each other by their service name. Since your docker-compose.yml names the database service postgres, that is the hostname pgAdmin uses to connect.
Your NestJS app connects using localhost because it runs on your host machine (not inside Docker) and reaches PostgreSQL through the published port 5432.
- In the Object Explorer on the left, expand Task API then Databases then task_api then Schemas then public then Tables.
- Right-click the task table and select View/Edit Data then View All Rows.
You should see all three tasks displayed in the Data Grid with their id, title, status, priority, and timestamp columns.
Can't see the task table?
If the Tables node appears empty, right-click it and select Refresh. TypeORM creates the table on first connection, so it should exist if your NestJS server started successfully.
If the server connection fails, double-check that you entered postgres (the service name) as the host, not localhost. From inside the pgAdmin container, localhost refers to the pgAdmin container itself.
Help me connect pgAdmin to my Docker PostgreSQL instance
Test the full CRUD lifecycle
Creating tasks proves the happy path works. Now you will test updates, deletes, and validation errors to confirm every layer handles edge cases correctly.
- Update a task's status from OPEN to DONE by running this command (replace the ID with one you copied earlier):
curl -X PATCH http://localhost:3000/tasks/[[TASK_ID="paste your task id here"]] \
-H "Content-Type: application/json" \
-d '{"status": "DONE"}'
You should see the task returned with status now set to DONE and the updatedAt timestamp changed.
- Delete that same task by running:
curl -X DELETE http://localhost:3000/tasks/[[TASK_ID="paste your task id here"]]
- Confirm the deletion by requesting all tasks:
curl http://localhost:3000/tasks
You should now see only two tasks in the response.
- Switch back to pgAdmin and click View/Edit Data then View All Rows on the task table again to refresh the view.
The Data Grid should now show only two rows, confirming the delete propagated to the database.
Why check both the API and pgAdmin?
The API response only proves your Controller and Service handled the request. Checking pgAdmin proves the Repository layer successfully committed the change to PostgreSQL. This end-to-end verification confirms all three layers work together.
- Test Zod validation by sending an invalid request with an empty body:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{}'
You should receive a 400 response with a JSON body showing the validation error. The response includes a message of Validation failed and an errors array indicating that title is required.
What does this prove?
The ZodValidationPipe intercepts the request before it ever reaches the Service or Repository. Invalid data never touches your business logic or database. This is exactly why validation lives at the Controller boundary in a layered architecture.
Getting 201 instead of 400?
Make sure you are sending Content-Type: application/json in the header. Without it, NestJS may not parse the body correctly, and the pipe receives undefined instead of an empty object.
Also verify that your @Body(new ZodValidationPipe(createTaskSchema)) decorator is in place on the create method in tasks.controller.ts.
Help me debug why my Zod validation is not rejecting invalid POST bodies
Secret mission
Add Query Filtering and Pagination
Your API returns every task at once. Real-world APIs let clients filter by status, priority, and paginate through large result sets. Extend GET /tasks with Zod-validated query parameters to make your API production-ready.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Docker containers (PostgreSQL on port 5432 and pgAdmin 4 on port 5050).
- Docker volume pgdata storing PostgreSQL data.
- Local project folder task-api on your Desktop.
- NestJS CLI installed globally via npm.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with your API.
- Your Docker containers continue running in the background and consume a small amount of memory.
- The NestJS CLI remains available globally for future projects.
- Your task-api folder stays on your Desktop.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and Docker images stay on disk so you can resume quickly.
- Stop the NestJS dev server by pressing Ctrl+C in the terminal where it is running.
- Stop the Docker containers by running this command from inside the task-api folder:
docker compose stop
- When you are ready to resume, run docker compose start from the task-api folder, then npm run start:dev to bring everything back up.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible.
- Stop the NestJS dev server by pressing Ctrl+C in the terminal where it is running.
- Stop and remove the Docker containers and the pgdata volume by running this command from inside the task-api folder:
docker compose down -v
- Navigate to your Desktop and delete the project folder.
macOS
cd ~/Desktop
rm -rf task-api
Windows
cd ~\Desktop
Remove-Item -Recurse -Force task-api
- Uninstall the NestJS CLI globally by running:
npm uninstall -g @nestjs/cli
Nice Work!
Nice Work!
You just built a production-grade Task Management REST API with NestJS 11, complete with layered architecture and Zod-powered validation.
You've learned how to:
- Build a NestJS 11 REST API with explicit 3-layer architecture separating Controllers, Services, and Repositories into independent, swappable layers.
- Create a reusable ZodValidationPipe for request validation, defining type-safe DTOs with z.infer and zero dependency on class-validator.
- Run a Docker Compose stack with PostgreSQL and pgAdmin 4, connecting your API via TypeORM and visually verifying persisted data.
- Secret Mission: Added query-based filtering and pagination with Zod-validated query parameters.
Ready to quiz yourself?