Repository Pattern
The Repository pattern places a thin, domain-shaped interface between your services and whatever ORM actually talks to the database. Instead of a service knowing about TypeORM Repository methods or Prisma’s prisma.user.findUnique, it speaks in terms of findById and save. That seam keeps domain logic free of persistence details, makes the storage engine swappable, and turns slow database integration tests into fast in-memory unit tests.
Why abstract the ORM
When a service injects a TypeORM repository or the Prisma client directly, it inherits every detail of that library: query-builder syntax, lazy relations, transaction semantics, and import paths. The domain layer becomes welded to the data layer, so a migration from one ORM to another — or even just unit testing — forces you to touch business code. The Repository pattern inverts that by defining the contract your domain wants and letting an adapter satisfy it.
// user.entity.ts — a plain domain model, ORM-agnostic
export interface User {
id: string;
email: string;
displayName: string;
}
Defining the repository interface
Start with the abstraction. Because TypeScript interfaces disappear at runtime, pair the interface with an injection token so Nest’s container can resolve it.
// user.repository.ts
import { User } from './user.entity';
export interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<User>;
}
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
The service now depends only on this contract — never on @nestjs/typeorm or @prisma/client.
// user.service.ts
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { User } from './user.entity';
import { UserRepository, USER_REPOSITORY } from './user.repository';
@Injectable()
export class UserService {
constructor(
@Inject(USER_REPOSITORY) private readonly users: UserRepository,
) {}
async rename(id: string, displayName: string): Promise<User> {
const user = await this.users.findById(id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return this.users.save({ ...user, displayName });
}
}
Implementing over TypeORM
The TypeORM adapter is the only place that imports ORM types. It translates between the persistence entity and the domain model and implements the interface verbatim.
// user.typeorm-entity.ts
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('users')
export class UserOrmEntity {
@PrimaryColumn('uuid') id: string;
@Column({ unique: true }) email: string;
@Column() displayName: string;
}
// typeorm-user.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { UserRepository } from './user.repository';
import { UserOrmEntity } from './user.typeorm-entity';
@Injectable()
export class TypeOrmUserRepository implements UserRepository {
constructor(
@InjectRepository(UserOrmEntity)
private readonly repo: Repository<UserOrmEntity>,
) {}
async findById(id: string): Promise<User | null> {
return this.repo.findOne({ where: { id } });
}
async findByEmail(email: string): Promise<User | null> {
return this.repo.findOne({ where: { email } });
}
async save(user: User): Promise<User> {
return this.repo.save(user);
}
}
Implementing over Prisma
Swapping persistence is now an isolated change: write a second adapter, bind it in the module, and no service is touched.
// prisma-user.repository.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { User } from './user.entity';
import { UserRepository } from './user.repository';
@Injectable()
export class PrismaUserRepository implements UserRepository {
constructor(private readonly prisma: PrismaService) {}
findById(id: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
findByEmail(email: string): Promise<User | null> {
return this.prisma.user.findUnique({ where: { email } });
}
save(user: User): Promise<User> {
return this.prisma.user.upsert({
where: { id: user.id },
create: user,
update: user,
});
}
}
Wiring the token in the module
The module is the composition root: it owns the decision of which implementation backs the token. Consumers stay oblivious.
// users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserService } from './user.service';
import { USER_REPOSITORY } from './user.repository';
import { TypeOrmUserRepository } from './typeorm-user.repository';
import { UserOrmEntity } from './user.typeorm-entity';
@Module({
imports: [TypeOrmModule.forFeature([UserOrmEntity])],
providers: [
UserService,
{ provide: USER_REPOSITORY, useClass: TypeOrmUserRepository },
],
exports: [UserService],
})
export class UsersModule {}
To move to Prisma, change one line — useClass: PrismaUserRepository — and update the imports. The service, controllers, and tests are unaffected.
| Binding | Provider definition | Resolved implementation |
|---|---|---|
| Production | { provide: USER_REPOSITORY, useClass: TypeOrmUserRepository } | TypeORM + Postgres |
| Alternate ORM | { provide: USER_REPOSITORY, useClass: PrismaUserRepository } | Prisma client |
| Unit test | { provide: USER_REPOSITORY, useValue: fakeRepo } | in-memory fake |
Map between the ORM entity and your domain model inside the adapter. Leaking ORM-decorated classes into services quietly re-couples the domain to the database and defeats the purpose of the pattern.
Testing without a database
Because the service depends on a token, a unit test supplies an in-memory double — no Postgres, no migrations, no Docker.
// user.service.spec.ts
import { Test } from '@nestjs/testing';
import { UserService } from './user.service';
import { UserRepository, USER_REPOSITORY } from './user.repository';
describe('UserService', () => {
let service: UserService;
const fakeRepo: jest.Mocked<UserRepository> = {
findById: jest.fn(),
findByEmail: jest.fn(),
save: jest.fn(),
};
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
UserService,
{ provide: USER_REPOSITORY, useValue: fakeRepo },
],
}).compile();
service = moduleRef.get(UserService);
});
it('renames an existing user', async () => {
fakeRepo.findById.mockResolvedValue({ id: '1', email: '[email protected]', displayName: 'Ada' });
fakeRepo.save.mockImplementation(async (u) => u);
const result = await service.rename('1', 'Ada L.');
expect(result.displayName).toBe('Ada L.');
expect(fakeRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ displayName: 'Ada L.' }),
);
});
});
Output:
PASS src/users/user.service.spec.ts
UserService
✓ renames an existing user (5 ms)
Tests: 1 passed, 1 total
Best practices
- Define the interface around the domain’s needs (
findByEmail), not the ORM’s surface (findOne({ where })). - Identify each repository with a
Symboltoken so TypeScript interfaces survive into the runtime container. - Keep all ORM imports inside the adapter; services and controllers should never reference
typeormor@prisma/client. - Translate between persistence entities and plain domain models in the adapter to prevent decorator leakage.
- Let the module own the
useClassbinding so swapping storage is a one-line, single-file change. - Override the token with
useValuein unit tests to keep them fast and free of database I/O. - Resist adding generic
find(query)methods — they re-expose ORM semantics and erode the abstraction.