nestjs-repository-pattern
NestJS Repository Pattern architecture and best practices for api-app. Use this skill when implementing data access layers, separating business logic from database queries, or refactoring services to use repositories. Applies to NestJS backend modules using Prisma ORM.
SKILL.md
| Name | nestjs-repository-pattern |
| Description | NestJS Repository Pattern architecture and best practices for api-app. Use this skill when implementing data access layers, separating business logic from database queries, or refactoring services to use repositories. Applies to NestJS backend modules using Prisma ORM. |
name: nestjs-repository-pattern description: NestJS Repository Pattern architecture and best practices for api-app. Use this skill when implementing data access layers, separating business logic from database queries, or refactoring services to use repositories. Applies to NestJS backend modules using Prisma ORM. license: MIT metadata: author: maintenance-management-system version: '1.0.0' organization: JustMT date: February 2026 abstract: Clean architecture guidelines for implementing repository pattern in NestJS applications. Defines clear separation between data access (repositories), business logic (services), and HTTP concerns (controllers). Includes ownership rules, naming conventions, transaction support, and error handling patterns.
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.
// ❌ INCORRECT: Direct Prisma call in service
@Injectable()
export class AssetService {
async findById(id: string) {
return await prisma.asset.findFirst({ where: { id } })
}
}
// ✅ CORRECT: Repository handles data access
@Injectable()
export class AssetsRepository {
async findById(id: string, siteId: string, select?: Prisma.AssetSelect) {
return prisma.asset.findFirst({ where: { id, siteId }, select })
}
}
@Injectable()
export class AssetService {
constructor(private readonly assetsRepo: AssetsRepository) {}
async findById(id: string, siteId: string) {
const asset = await this.assetsRepo.findById(id, siteId, this.select)
if (!asset) throw new NotFoundException('Asset not found')
return asset
}
}
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:
// ✅ CORRECT: Auth module imports users module to access UsersRepository
@Module({
imports: [UsersModule], // Import the module
providers: [AuthService],
})
export class AuthModule {}
@Injectable()
export class AuthService {
constructor(
private readonly usersRepo: UsersRepository // Inject from imported module
) {}
}
Anti-Pattern:
// ❌ INCORRECT: Duplicating user queries in auth.repository.ts
// 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.
// ✅ CORRECT: Repository returns null, no exceptions
@Injectable()
export class UsersRepository {
async findById(id: string): Promise<User | null> {
return prisma.user.findUnique({ where: { id } })
// Returns null if not found - let service decide what to do
}
}
// ✅ CORRECT: Service adds business logic and HTTP exceptions
@Injectable()
export class UserService {
async getUser(id: string): Promise<UserDto> {
const user = await this.usersRepo.findById(id)
if (!user) {
throw new NotFoundException('User not found') // HTTP exception
}
if (!user.isActive) {
throw new ForbiddenException('User is inactive') // Business rule
}
return this.transformToDto(user) // Business logic
}
}
4. Method Naming Conventions (HIGH)
Rule: Use consistent prefixes that signal behavior.
Repository Methods:
find*- Returnsnullif not foundcreate- Creates and returns recordupdate- Updates and returns recordsoftDelete/delete- Deletes recordcount- Returns count
@Injectable()
export class UsersRepository {
// Returns null if not found
async findById(id: string): Promise<User | null>
async findByEmail(email: string): Promise<User | null>
// Returns created/updated record
async create(data: CreateData): Promise<User>
async update(id: string, data: UpdateData): Promise<User>
// Soft delete
async softDelete(id: string): Promise<User>
}
Service Methods:
find*- May return null or throw (business decision)get*- Expected to exist, throws if not foundcreate/update/remove- Operations with validation
@Injectable()
export class UserService {
// Throws NotFoundException if not found
async getUser(id: string): Promise<UserDto>
// Returns null or record
async findUserByEmail(email: string): Promise<UserDto | null>
}
5. Transaction Support (HIGH)
Rule: All repository methods MUST accept optional transaction parameter.
// ✅ CORRECT: Repository accepts optional transaction
@Injectable()
export class UsersRepository {
async findById(
id: string,
tx?: Prisma.TransactionClient // Optional transaction
): Promise<User | null> {
const db = tx ?? prisma // Use transaction if provided, else prisma
return db.user.findUnique({ where: { id } })
}
async upsertUserProfile(payload: CreateUserData, tx?: Prisma.TransactionClient) {
const db = tx ?? prisma
// If already in transaction, execute directly
if (tx) {
return tx.user.upsert({
/* ... */
})
}
// Otherwise create new transaction
return prisma.$transaction(async (innerTx) => {
return innerTx.user.upsert({
/* ... */
})
})
}
}
// ✅ CORRECT: Service coordinates transactions
@Injectable()
export class UserService {
async complexOperation(userId: string) {
return prisma.$transaction(async (tx) => {
const user = await this.usersRepo.findById(userId, tx)
const sites = await this.sitesRepo.findByUser(userId, tx)
// All operations use same transaction
return { user, sites }
})
}
}
6. Error Handling (MEDIUM)
Rule: Repositories return null or throw Prisma errors. Services transform to HTTP exceptions.
// ✅ CORRECT: Repository doesn't catch errors
@Injectable()
export class AssetsRepository {
async create(data: CreateAssetData) {
return prisma.asset.create({ data }) // Prisma errors bubble up
}
}
// ✅ CORRECT: Service catches and transforms errors
@Injectable()
export class AssetService {
async create(dto: CreateAssetDto) {
try {
return await this.assetsRepo.create({
name: dto.name.trim(), // Business logic
code: dto.code.trim(),
/* ... */
})
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new ConflictException('Asset already exists') // HTTP exception
}
}
throw error
}
}
}
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.
// ✅ CORRECT: Module setup
@Module({
imports: [ListModule, AuthModule],
controllers: [AssetController],
providers: [AssetService, AssetsRepository], // Provide repository
exports: [AssetService, AssetsRepository], // Export for other modules
})
export class AssetsModule {}
// ✅ CORRECT: Service injection
@Injectable()
export class AssetService {
constructor(
private readonly assetsRepo: AssetsRepository // Inject repository
) {}
}
Common Patterns
Pattern 1: Simple CRUD
// Repository
@Injectable()
export class AssetsRepository {
async findById(id: string, siteId: string, select?: Prisma.AssetSelect) {
return prisma.asset.findFirst({ where: { id, siteId, isDeleted: false }, select })
}
async create(data: CreateAssetData, select?: Prisma.AssetSelect) {
return prisma.asset.create({ data, select })
}
async update(id: string, data: Prisma.AssetUpdateInput, select?: Prisma.AssetSelect) {
return prisma.asset.update({ where: { id }, data, select })
}
async softDelete(id: string) {
return prisma.asset.update({ where: { id }, data: { isDeleted: true } })
}
}
// Service
@Injectable()
export class AssetService {
constructor(private readonly assetsRepo: AssetsRepository) {}
async findById(siteId: string, id: string): Promise<AssetDto> {
const asset = await this.assetsRepo.findById(id, siteId, this.select)
if (!asset) throw new NotFoundException('Asset not found')
return asset
}
async create(siteId: string, dto: CreateAssetDto): Promise<AssetDto> {
try {
return await this.assetsRepo.create(
{
siteId,
name: dto.name.trim(),
code: dto.code.trim(),
},
this.select
)
} catch (error) {
this.handlePrismaError(error)
}
}
}
Pattern 2: Complex Queries with Relations
// Repository
@Injectable()
export class UsersRepository {
async findUserRolesByIdAndSite(
userId: string,
siteId: string,
tenantId: string,
appScopes: AppScope[]
) {
return prisma.userRole.findMany({
where: {
userId,
siteId,
role: { siteId, appScope: { in: appScopes } },
},
select: {
role: {
select: {
id: true,
code: true,
name: true,
rolePermissions: {
where: { permission: { tenantId, appScope: { in: appScopes } } },
select: { permission: { select: { code: true } } },
},
},
},
},
})
}
}
Pattern 3: Assertion Helper
// Service helper method
private async assertExists(siteId: string, id: string): Promise<void> {
const asset = await this.assetsRepo.findById(id, siteId, { id: true })
if (!asset) {
throw new NotFoundException('Asset not found')
}
}
// Usage
async update(siteId: string, id: string, dto: UpdateAssetDto) {
await this.assertExists(siteId, id) // Throws if not found
return this.assetsRepo.update(id, { /* ... */ })
}
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
Related Documentation
- See
apps/api-app/REFACTORING_SUMMARY.mdfor implementation details - See
apps/api-app/ARCHITECTURE.mdfor module structure - See
docs/ARCHITECTURE.mdfor overall system architecture