In my last blog, I roasted developers who treat the repository pattern like a junk drawer—cramming it full of business logic until it screams for mercy. But I'm not a monster, so consider this my redemption arc.
You can read the last blog here.
And for those of you who don't want to read, here is the repo to this setup.
I've assembled a therapy session disguised as a step-by-step guide: how to implement the repository pattern in NestJS without making your codebase cry. Clean architecture, zero judgment, and absolutely no "but it works on my machine" excuses allowed. 🚀
What You'll Learn
By the end of this guide, you'll have a fully working NestJS application with:
- ✅ Proper separation of concerns
- ✅ Clean, testable repositories
- ✅ Business logic in the right placec
- ✅ Easy-to-swap database implementations
- ✅ Complete test coverage ### Project Structure Here's what we're building:
src
├── application
│ ├── dtos
│ │ └── create-user.dto.ts
│ └── use-cases
│ ├── __tests__
│ │ └── create-user.use-case.spec.ts
│ ├── create-user.use-case.ts
│ ├── get-user.use-case.ts
│ └── update-user.use-case.ts
├── domain
│ ├── entities
│ │ └── user.entity.ts
│ ├── repositories
│ │ └── user.repository.interface.ts
│ └── value-objects
│ └── email.vo.ts
├── infrastructure
│ ├── database
│ │ ├── entities
│ │ │ └── user.orm-entity.ts
│ │ └── repositories
│ │ ├── in-memory-user.repository.ts
│ │ └── typeorm-user.repository.ts
│ └── persistence.module.ts
├── presentation
│ ├── controllers
│ │ └── users.controller.ts
│ └── presentation.module.ts
├── app.controller.spec.ts
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts
Step 1: Domain Layer - The Heart of Your App
The domain layer contains your business entities and rules. It doesn't know about databases, APIs or frameworks.
Domain Entity
// src/domain/entities/user.entity.ts
import { Email } from '@domain/value-objects/email.vo';
export class User {
constructor(
public readonly id: string,
public readonly email: Email,
public readonly name: string,
public readonly isActive: boolean,
public readonly createdAt: Date,
public readonly updatedAt: Date,
) {}
static create(props: { id: string; email: string; name: string }): User {
const emailVO = Email.create(props.email);
const now = new Date();
return new User(props.id, emailVO, props.name, true, now, now);
}
updateName(newName: string): User {
return new User(
this.id,
this.email,
newName,
this.isActive,
this.createdAt,
new Date(),
);
}
deactivate(): User {
return new User(
this.id,
this.email,
this.name,
false,
this.createdAt,
new Date(),
);
}
activate(): User {
return new User(
this.id,
this.email,
this.name,
true,
this.createdAt,
new Date(),
);
}
}
Value Object ( Optional but recommended )
// src/domain/value-objects/email.vo.ts
export class Email {
private constructor(public readonly value: string) {}
static create(email: string): Email {
if (!this.isValid(email)) {
throw new Error('Invalid email format');
}
return new Email(email.toLowerCase().trim());
}
private static isValid(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
equals(other: Email): boolean {
return this.value === other.value;
}
}
Repository Interface
// src/domain/repositories/user.repository.interface.ts
import { User } from '@domain/entities/user.entity';
export interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
findAll(): Promise<User[]>;
save(user: User): Promise<User>;
delete(id: string): Promise<void>;
exists(email: string): Promise<boolean>;
}
export const USER_REPOSITORY = Symbol('IUserRepository');
Step 2: Infrastructure Layer - Database Implementation
This layer handles the "boring" stuff - talking to your database.
TypeORM Entity (ORM Mapping)
// src/infrastructure/database/entities/user.orm-entity.ts
import {
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('users')
export class UserOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ unique: true })
email: string;
@Column()
name: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}
TypeORM Repository Implementation
// src/infrastructure/database/repositories/typeorm-user.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IUserRepository } from '@domain/repositories/user.repository.interface';
import { UserOrmEntity } from '@infrastructure/database/entities/user.orm-entity';
import { Repository } from 'typeorm';
import { User } from '@domain/entities/user.entity';
import { Email } from '@domain/value-objects/email.vo';
@Injectable()
export class TypeOrmUserRepository implements IUserRepository {
constructor(
@InjectRepository(UserOrmEntity)
private readonly repository: Repository<UserOrmEntity>,
) {}
async findById(id: string): Promise<User | null> {
const entity = await this.repository.findOne({ where: { id } });
return entity ? this.toDomain(entity) : null;
}
async findByEmail(email: string): Promise<User | null> {
const entity = await this.repository.findOne({ where: { email } });
return entity ? this.toDomain(entity) : null;
}
async findAll(): Promise<User[]> {
const entities = await this.repository.find();
return entities.map((entity) => this.toDomain(entity));
}
async save(user: User): Promise<User> {
const entity = this.toOrmEntity(user);
const savedEntity = await this.repository.save(entity);
return this.toDomain(savedEntity);
}
async delete(id: string): Promise<void> {
await this.repository.delete(id);
}
async exists(email: string): Promise<boolean> {
const count = await this.repository.count({ where: { email } });
return count > 0;
}
// Mapping methods - convert between domain and ORM entities
private toDomain(entity: UserOrmEntity): User {
return new User(
entity.id,
Email.create(entity.email),
entity.name,
entity.isActive,
entity.createdAt,
entity.updatedAt,
);
}
private toOrmEntity(user: User): UserOrmEntity {
const entity = new UserOrmEntity();
entity.id = user.id;
entity.email = user.email.value;
entity.name = user.name;
entity.isActive = user.isActive;
entity.createdAt = user.createdAt;
entity.updatedAt = user.updatedAt;
return entity;
}
}
In-Memory Repository ( For Testing )
// src/infrastructure/database/repositories/in-memory-user.repository.ts
import { Injectable } from '@nestjs/common';
import { User } from '@domain/entities/user.entity';
import { IUserRepository } from '@domain/repositories/user.repository.interface';
@Injectable()
export class InMemoryUserRepository implements IUserRepository {
private users: Map<string, User> = new Map();
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async findByEmail(email: string): Promise<User | null> {
const users = Array.from(this.users.values());
return users.find((user) => user.email.value === email) || null;
}
async findAll(): Promise<User[]> {
return Array.from(this.users.values());
}
async save(user: User): Promise<User> {
this.users.set(user.id, user);
return user;
}
async delete(id: string): Promise<void> {
this.users.delete(id);
}
async exists(email: string): Promise<boolean> {
const users = Array.from(this.users.values());
return users.some((user) => user.email.value === email);
}
// Helper for testing
clear(): void {
this.users.clear();
}
}
Persistence Module
// src/infrastructure/persistence.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserOrmEntity } from '@infrastructure/database/entities/user.orm-entity';
import { TypeOrmUserRepository } from '@infrastructure/database/repositories/typeorm-user.repository';
import { USER_REPOSITORY } from '@domain/repositories/user.repository.interface';
@Module({
imports: [TypeOrmModule.forFeature([UserOrmEntity])],
providers: [
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
},
],
exports: [USER_REPOSITORY],
})
export class PersistenceModule {}
Step 3: Application Layer - Use Cases
This is where your database logic orchestration lives.
DTOs
// src/application/dtos/create-user.dto.ts
import {
IsBoolean,
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
export class CreateUserDto {
@IsEmail()
@IsNotEmpty()
email: string;
@IsString()
@IsNotEmpty()
@MinLength(2)
name: string;
}
export class UpdateUserDto {
@IsString()
@IsNotEmpty()
@MinLength(2)
name?: string;
@IsBoolean()
@IsOptional()
isActive?: boolean;
}
Use Cases
// src/application/use-cases/create-user.use-case.ts
import { Inject, Injectable, ConflictException } from '@nestjs/common';
import { randomUUID } from 'crypto';
import {
IUserRepository,
USER_REPOSITORY,
} from '@domain/repositories/user.repository.interface';
import { User } from '@domain/entities/user.entity';
import { CreateUserDto } from '@application/dtos/create-user.dto';
@Injectable()
export class CreateUserUseCase {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: IUserRepository,
) {}
async execute(dto: CreateUserDto): Promise<User> {
// Check if user already exists
const emailExists = await this.userRepository.exists(dto.email);
if (emailExists) {
throw new ConflictException('User with this email already exists');
}
// Create domain entity
const user = User.create({
id: randomUUID(),
email: dto.email,
name: dto.name,
});
// Persist
return await this.userRepository.save(user);
}
}
// src/application/use-cases/update-user.use-case.ts
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
IUserRepository,
USER_REPOSITORY,
} from '@domain/repositories/user.repository.interface';
import { User } from '@domain/entities/user.entity';
import { UpdateUserDto } from '@application/dtos/create-user.dto';
@Injectable()
export class UpdateUserUseCase {
constructor(
@Inject(USER_REPOSITORY)
private readonly userRepository: IUserRepository,
) {}
async execute(id: string, dto: UpdateUserDto): Promise<User> {
const user = await this.userRepository.findById(id);
if (!user) {
throw new NotFoundException(`User with id ${id} not found`);
}
let updatedUser = user;
if (dto.name) {
updatedUser = updatedUser.updateName(dto.name);
}
if (dto.isActive !== undefined) {
updatedUser = dto.isActive
? updatedUser.activate()
: updatedUser.deactivate();
}
return await this.userRepository.save(updatedUser);
}
}
Step 4: Presentation Layer - API Controllers
// src/presentation/controllers/users.controller.ts
import {
Controller,
Get,
Post,
Put,
Body,
Param,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { CreateUserUseCase } from '@application/use-cases/create-user.use-case';
import { GetUserUseCase } from '@application/use-cases/get-user.use-case';
import { UpdateUserUseCase } from '@application/use-cases/update-user.use-case';
import {
CreateUserDto,
UpdateUserDto,
} from '@application/dtos/create-user.dto';
@Controller('users')
export class UsersController {
constructor(
private readonly createUser: CreateUserUseCase,
private readonly getUser: GetUserUseCase,
private readonly updateUser: UpdateUserUseCase,
) {}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: CreateUserDto) {
const user = await this.createUser.execute(dto);
return this.toResponse(user);
}
@Get()
async findAll() {
const users = await this.getUser.all();
return users.map((user) => this.toResponse(user));
}
@Get(':id')
async findOne(@Param('id') id: string) {
const user = await this.getUser.byId(id);
return this.toResponse(user);
}
@Put(':id')
async update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
const user = await this.updateUser.execute(id, dto);
return this.toResponse(user);
}
// Helper to convert domain entity to API response
private toResponse(user: any) {
return {
id: user.id,
email: user.email.value,
name: user.name,
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
}
// src/presentation/presentation.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from '@presentation/controllers/users.controller';
import { CreateUserUseCase } from '@application/use-cases/create-user.use-case';
import { GetUserUseCase } from '@application/use-cases/get-user.use-case';
import { UpdateUserUseCase } from '@application/use-cases/update-user.use-case';
import { PersistenceModule } from '@infrastructure/persistence.module';
@Module({
imports: [PersistenceModule],
controllers: [UsersController],
providers: [CreateUserUseCase, GetUserUseCase, UpdateUserUseCase],
})
export class PresentationModule {}
Step 5: Root Module Setup
// src/app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { PresentationModule } from '@presentation/presentation.module';
@Module({
imports: [
ConfigModule.forRoot(),
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT) || 5432,
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_DATABASE || 'nest_repo_pattern',
entities: [__dirname + '/**/*.orm-entity{.ts,.js}'],
synchronize: process.env.NODE_ENV !== 'production',
}),
PresentationModule,
],
})
export class AppModule {}
Step 6: Testing:
// src/application/use-cases/__tests__/create-user.use-case.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException } from '@nestjs/common';
import { InMemoryUserRepository } from '../../../infrastructure/database/repositories/in-memory-user.repository';
import { USER_REPOSITORY } from '../../../domain/repositories/user.repository.interface';
import { CreateUserUseCase } from '../create-user.use-case';
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let repository: InMemoryUserRepository;
beforeEach(async () => {
repository = new InMemoryUserRepository();
const module: TestingModule = await Test.createTestingModule({
providers: [
CreateUserUseCase,
{
provide: USER_REPOSITORY,
useValue: repository,
},
],
}).compile();
useCase = module.get<CreateUserUseCase>(CreateUserUseCase);
});
afterEach(() => {
repository.clear();
});
it('should create a new user', async () => {
const dto = {
email: 'test@example.com',
name: 'Test User',
};
const user = await useCase.execute(dto);
expect(user).toBeDefined();
expect(user.email.value).toBe(dto.email);
expect(user.name).toBe(dto.name);
expect(user.isActive).toBe(true);
});
it('should throw ConflictException if email exists', async () => {
const dto = {
email: 'test@example.com',
name: 'Test User',
};
await useCase.execute(dto);
await expect(useCase.execute(dto)).rejects.toThrow(ConflictException);
});
it('should throw error for invalid email', async () => {
const dto = {
email: 'invalid-email',
name: 'Test User',
};
await expect(useCase.execute(dto)).rejects.toThrow();
});
});
Additional Files:
package.json
dependencies
{
"name": "repository-pattern-setup",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"migration:run": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:run",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/typeorm": "^11.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"typeorm": "^0.3.27"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.3.1",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^8.0.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.7.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.4.5",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
}
}
jest.config.js
configuration:
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: 'src',
moduleFileExtensions: ['ts', 'js', 'json'],
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
},
moduleNameMapper: {
'^@domain/(.*)$': '<rootDir>/domain/$1',
'^@application/(.*)$': '<rootDir>/application/$1',
'^@infrastructure/(.*)$': '<rootDir>/infrastructure/$1',
'^@presentation/(.*)$': '<rootDir>/presentation/$1',
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
};
.env.example
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=postgres
DB_PASSWORD=postgres
DB_DATABASE=nest_repo_pattern
# Application
NODE_ENV=development
PORT=3000
docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
ports:
- '5432:5432'
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: nest_repo_pattern
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
Running the project
# Install dependencies
npm install
# Start database
docker-compose up -d
# Run migrations (if using migrations instead of synchronize)
npm run migration:run
# Start development server
npm run start:dev
# Run tests
npm test
API Endpoints Results & Testing Results:
- POST -
http://localhost:3000/users
| Create user:
- GET -
http://localhost:3000/users
| Get all users:
- GET -
http://localhost:3000/users/{userId}
| Get a user:
-
npm test
results:
Key Takeaways
- Repositories only handle data access - No business logic whatsoever
- Use cases orchestrate business logic - They're your application's entry points
- Domain entities contain business rules - Keep them pure and testable
- Interfaces enable flexibility - Swap implementations easily
- Layer separation is crucial - Each layer has a clear responsibility
What's Next?
Consider adding:
- Event-driven architecture ( Domain Events )
- CQRS for complex read operations
- Pagination and filtering
- Authentication and authorization
- API documentation and authorization
- More comprehensive error handling ( try putting something else other than the UUID in the get single user endpoint and you'll see an ugly response )
Resources
Happy coding! 🎉 Remember: Clean architecture is a journey, not a destination. Start simple, refactor as you learn, and most importantly - keep your repositories boring!
Any feedback is welcomed 🫶🏽
Top comments (0)