NestJS Repository Pattern Best Practices
Clean architecture guidelines for implementing repository pattern in NestJS applications with Prisma ORM.
When to Apply
Reference these guidelines when:
- Creating new NestJS modules with database access
- Refactoring services that contain direct Prisma/DB calls
- Implementing data access layers
- Writing queries for User, Asset, Site, or other entities
- Setting up transaction support
- Organizing code for testability and maintainability
Core Principles
1. Separation of Concerns (CRITICAL)
Rule: All Prisma/DB queries MUST live in repository classes, not in services or controllers.
typescript
1// ❌ INCORRECT: Direct Prisma call in service
2@Injectable()
3export class AssetService {
4 async findById(id: string) {
5 return await prisma.asset.findFirst({ where: { id } })
6 }
7}
8
9// ✅ CORRECT: Repository handles data access
10@Injectable()
11export class AssetsRepository {
12 async findById(id: string, siteId: string, select?: Prisma.AssetSelect) {
13 return prisma.asset.findFirst({ where: { id, siteId }, select })
14 }
15}
16
17@Injectable()
18export class AssetService {
19 constructor(private readonly assetsRepo: AssetsRepository) {}
20
21 async findById(id: string, siteId: string) {
22 const asset = await this.assetsRepo.findById(id, siteId, this.select)
23 if (!asset) throw new NotFoundException('Asset not found')
24 return asset
25 }
26}
Why: Enables testing, reusability, and clear architectural boundaries.
2. Repository Ownership (CRITICAL)
Rule: Each entity gets ONE repository. The repository is owned by the module that manages that entity as its "main table."
Entity Ownership Map:
| Entity | Repository | Module | Location |
|---|
| User | UsersRepository | users | modules/users/repositories/users.repository.ts |
| Asset | AssetsRepository | assets | modules/assets/repositories/assets.repository.ts |
| Site | SitesRepository | site | modules/site/repositories/sites.repository.ts |
Cross-Module Usage:
typescript
1// ✅ CORRECT: Auth module imports users module to access UsersRepository
2@Module({
3 imports: [UsersModule], // Import the module
4 providers: [AuthService],
5})
6export class AuthModule {}
7
8@Injectable()
9export class AuthService {
10 constructor(
11 private readonly usersRepo: UsersRepository // Inject from imported module
12 ) {}
13}
Anti-Pattern:
typescript
1// ❌ INCORRECT: Duplicating user queries in auth.repository.ts
2// If you need user queries, use UsersRepository from users module
3. Repository Content (HIGH)
Rule: Repositories contain ONLY data access code. No business logic, no HTTP exceptions.
typescript
1// ✅ CORRECT: Repository returns null, no exceptions
2@Injectable()
3export class UsersRepository {
4 async findById(id: string): Promise<User | null> {
5 return prisma.user.findUnique({ where: { id } })
6 // Returns null if not found - let service decide what to do
7 }
8}
9
10// ✅ CORRECT: Service adds business logic and HTTP exceptions
11@Injectable()
12export class UserService {
13 async getUser(id: string): Promise<UserDto> {
14 const user = await this.usersRepo.findById(id)
15 if (!user) {
16 throw new NotFoundException('User not found') // HTTP exception
17 }
18 if (!user.isActive) {
19 throw new ForbiddenException('User is inactive') // Business rule
20 }
21 return this.transformToDto(user) // Business logic
22 }
23}
4. Method Naming Conventions (HIGH)
Rule: Use consistent prefixes that signal behavior.
Repository Methods:
find* - Returns null if not found
create - Creates and returns record
update - Updates and returns record
softDelete / delete - Deletes record
count - Returns count
typescript
1@Injectable()
2export class UsersRepository {
3 // Returns null if not found
4 async findById(id: string): Promise<User | null>
5 async findByEmail(email: string): Promise<User | null>
6
7 // Returns created/updated record
8 async create(data: CreateData): Promise<User>
9 async update(id: string, data: UpdateData): Promise<User>
10
11 // Soft delete
12 async softDelete(id: string): Promise<User>
13}
Service Methods:
find* - May return null or throw (business decision)
get* - Expected to exist, throws if not found
create / update / remove - Operations with validation
typescript
1@Injectable()
2export class UserService {
3 // Throws NotFoundException if not found
4 async getUser(id: string): Promise<UserDto>
5
6 // Returns null or record
7 async findUserByEmail(email: string): Promise<UserDto | null>
8}
5. Transaction Support (HIGH)
Rule: All repository methods MUST accept optional transaction parameter.
typescript
1// ✅ CORRECT: Repository accepts optional transaction
2@Injectable()
3export class UsersRepository {
4 async findById(
5 id: string,
6 tx?: Prisma.TransactionClient // Optional transaction
7 ): Promise<User | null> {
8 const db = tx ?? prisma // Use transaction if provided, else prisma
9 return db.user.findUnique({ where: { id } })
10 }
11
12 async upsertUserProfile(
13 payload: CreateUserData,
14 tx?: Prisma.TransactionClient
15 ) {
16 const db = tx ?? prisma
17
18 // If already in transaction, execute directly
19 if (tx) {
20 return tx.user.upsert({ /* ... */ })
21 }
22
23 // Otherwise create new transaction
24 return prisma.$transaction(async (innerTx) => {
25 return innerTx.user.upsert({ /* ... */ })
26 })
27 }
28}
29
30// ✅ CORRECT: Service coordinates transactions
31@Injectable()
32export class UserService {
33 async complexOperation(userId: string) {
34 return prisma.$transaction(async (tx) => {
35 const user = await this.usersRepo.findById(userId, tx)
36 const sites = await this.sitesRepo.findByUser(userId, tx)
37 // All operations use same transaction
38 return { user, sites }
39 })
40 }
41}
6. Error Handling (MEDIUM)
Rule: Repositories return null or throw Prisma errors. Services transform to HTTP exceptions.
typescript
1// ✅ CORRECT: Repository doesn't catch errors
2@Injectable()
3export class AssetsRepository {
4 async create(data: CreateAssetData) {
5 return prisma.asset.create({ data }) // Prisma errors bubble up
6 }
7}
8
9// ✅ CORRECT: Service catches and transforms errors
10@Injectable()
11export class AssetService {
12 async create(dto: CreateAssetDto) {
13 try {
14 return await this.assetsRepo.create({
15 name: dto.name.trim(), // Business logic
16 code: dto.code.trim(),
17 /* ... */
18 })
19 } catch (error) {
20 if (error instanceof Prisma.PrismaClientKnownRequestError) {
21 if (error.code === 'P2002') {
22 throw new ConflictException('Asset already exists') // HTTP exception
23 }
24 }
25 throw error
26 }
27 }
28}
7. Module Organization (MEDIUM)
Rule: Follow consistent folder structure for repositories.
modules/
├── users/
│ ├── users.module.ts # Exports UsersRepository
│ └── repositories/
│ └── users.repository.ts # All User queries
├── assets/
│ ├── assets.module.ts # Provides AssetsRepository
│ ├── controllers/
│ │ └── asset.controller.ts
│ ├── services/
│ │ └── asset.service.ts # Uses AssetsRepository
│ └── repositories/
│ └── assets.repository.ts # All Asset queries
└── site/
├── site.module.ts
├── controllers/
├── services/
└── repositories/
└── sites.repository.ts
8. Dependency Injection (MEDIUM)
Rule: Repositories are provided at module level and injected into services.
typescript
1// ✅ CORRECT: Module setup
2@Module({
3 imports: [ListModule, AuthModule],
4 controllers: [AssetController],
5 providers: [AssetService, AssetsRepository], // Provide repository
6 exports: [AssetService, AssetsRepository], // Export for other modules
7})
8export class AssetsModule {}
9
10// ✅ CORRECT: Service injection
11@Injectable()
12export class AssetService {
13 constructor(
14 private readonly assetsRepo: AssetsRepository // Inject repository
15 ) {}
16}
Common Patterns
Pattern 1: Simple CRUD
typescript
1// Repository
2@Injectable()
3export class AssetsRepository {
4 async findById(id: string, siteId: string, select?: Prisma.AssetSelect) {
5 return prisma.asset.findFirst({ where: { id, siteId, isDeleted: false }, select })
6 }
7
8 async create(data: CreateAssetData, select?: Prisma.AssetSelect) {
9 return prisma.asset.create({ data, select })
10 }
11
12 async update(id: string, data: Prisma.AssetUpdateInput, select?: Prisma.AssetSelect) {
13 return prisma.asset.update({ where: { id }, data, select })
14 }
15
16 async softDelete(id: string) {
17 return prisma.asset.update({ where: { id }, data: { isDeleted: true } })
18 }
19}
20
21// Service
22@Injectable()
23export class AssetService {
24 constructor(private readonly assetsRepo: AssetsRepository) {}
25
26 async findById(siteId: string, id: string): Promise<AssetDto> {
27 const asset = await this.assetsRepo.findById(id, siteId, this.select)
28 if (!asset) throw new NotFoundException('Asset not found')
29 return asset
30 }
31
32 async create(siteId: string, dto: CreateAssetDto): Promise<AssetDto> {
33 try {
34 return await this.assetsRepo.create({
35 siteId,
36 name: dto.name.trim(),
37 code: dto.code.trim(),
38 }, this.select)
39 } catch (error) {
40 this.handlePrismaError(error)
41 }
42 }
43}
Pattern 2: Complex Queries with Relations
typescript
1// Repository
2@Injectable()
3export class UsersRepository {
4 async findUserRolesByIdAndSite(
5 userId: string,
6 siteId: string,
7 tenantId: string,
8 appScopes: AppScope[]
9 ) {
10 return prisma.userRole.findMany({
11 where: {
12 userId,
13 siteId,
14 role: { siteId, appScope: { in: appScopes } }
15 },
16 select: {
17 role: {
18 select: {
19 id: true,
20 code: true,
21 name: true,
22 rolePermissions: {
23 where: { permission: { tenantId, appScope: { in: appScopes } } },
24 select: { permission: { select: { code: true } } }
25 }
26 }
27 }
28 }
29 })
30 }
31}
Pattern 3: Assertion Helper
typescript
1// Service helper method
2private async assertExists(siteId: string, id: string): Promise<void> {
3 const asset = await this.assetsRepo.findById(id, siteId, { id: true })
4 if (!asset) {
5 throw new NotFoundException('Asset not found')
6 }
7}
8
9// Usage
10async update(siteId: string, id: string, dto: UpdateAssetDto) {
11 await this.assertExists(siteId, id) // Throws if not found
12 return this.assetsRepo.update(id, { /* ... */ })
13}
When NOT to Use Repository
- read-only utilities: Simple helper functions that don't need injection
- One-off scripts: Migration scripts or seeders (can use Prisma directly)
- ListService delegate: List/pagination queries can pass Prisma delegate to ListService
Migration Checklist
When refactoring existing code to use repositories:
- ✅ Create repository file in
repositories/ folder
- ✅ Move all Prisma queries from service to repository
- ✅ Add transaction support (
tx?: Prisma.TransactionClient) to all methods
- ✅ Make repositories return null for not-found cases
- ✅ Update service to inject repository
- ✅ Add HTTP exceptions in service layer
- ✅ Update module to provide/export repository
- ✅ Verify no direct Prisma calls remain in service
- ✅ Check cross-module dependencies (import correct module)
- ✅ Run tests and check for compilation errors
- See
apps/api-app/REFACTORING_SUMMARY.md for implementation details
- See
apps/api-app/ARCHITECTURE.md for module structure
- See
docs/ARCHITECTURE.md for overall system architecture