DEV Community

Cover image for Repository Pattern in NestJS: Do It Right or Go Home
Adam - The Developer
Adam - The Developer

Posted on

Repository Pattern in NestJS: Do It Right or Go Home

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
Enter fullscreen mode Exit fullscreen mode

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(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
  }
}

Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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',
};
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

API Endpoints Results & Testing Results:

  1. POST - http://localhost:3000/users | Create user:

  1. GET - http://localhost:3000/users | Get all users:

  1. GET - http://localhost:3000/users/{userId} | Get a user:

  1. npm test results:

Key Takeaways

  1. Repositories only handle data access - No business logic whatsoever
  2. Use cases orchestrate business logic - They're your application's entry points
  3. Domain entities contain business rules - Keep them pure and testable
  4. Interfaces enable flexibility - Swap implementations easily
  5. 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)