If you use NestJS with Prisma, you've probably had this moment: you open a new
module, look at the previous one, and copy-paste the same methods you always do.
getAll, getById, create, update, delete.
I did that for months. One day I counted: 12 modules, 5 methods each, almost
identical. I decided to fix it.
In this post I'll walk you through building a reusable base layer — and where
TypeScript will surprise you along the way.
What We're Building
A BaseRepository and a BaseService that eliminate CRUD repetition without
creating type bureaucracy. By the end, a pure CRUD service will look like this:
@Injectable()
export class StatusService extends BaseService<
Status,
CreateStatusDto,
UpdateStatusDto
> {
constructor(repo: StatusRepository) {
super(repo)
}
}
That's it. No reimplementing getAll, getById, create, update, delete.
Step 1: Identify What's the Same and What's Different
Before abstracting anything, list what every repository has in common:
-
findManyalways withdeletedAt: null(soft delete pattern) -
findFirstalways throwingNotFoundExceptionif nothing is found -
createandupdatevia Prisma -
try/catchmapping Prisma errors to NestJS exceptions - Soft delete by setting
deletedAt
And what varies between them:
- The Prisma delegate used (
prisma.status,prisma.product, etc.) - Entity and DTO types
- Business validation logic (checking for duplicate names, etc.)
- Specific includes and ordering
Everything that's the same goes into the base class. Everything that varies
stays in the concrete repository.
Step 2: The Prisma Delegate as a Class Generic
Each repository uses a different delegate. That's the only type that needs to
be a class-level generic — because it determines which operations are available
on this.model.
Entity and DTO types vary per operation, not per class. They go as method-level
generics — you'll understand why this matters soon.
// src/shared/repositories/base/base.repository.ts
abstract class BaseRepository<Model extends IBaseModel> {
constructor(
protected readonly errorService: ErrorService,
private readonly model: Model,
private readonly entityName: string = 'Record',
protected readonly paginationService?: PaginationService,
) {}
async findAll<Entity>(params?: FindMany): Promise<Entity[]> {
return this.executeFnWithTryCatch(() =>
this.model.findMany({
...params,
where: { ...params?.where, deletedAt: null },
}) as Promise<Entity[]>
)
}
async findItem<Entity>(params: FindByField): Promise<Entity> {
const found = await this.executeFnWithTryCatch(() =>
this.model.findFirst({
...params,
where: { ...params?.where, deletedAt: null },
}) as Promise<Entity | null>
)
if (!found)
throw new NotFoundException(`${this.entityName} not found`)
return found
}
async insert<CreateDto, Entity>(
params: Insert<CreateDto>,
): Promise<Entity> {
return this.executeFnWithTryCatch(() =>
this.model.create(params) as Promise<Entity>
)
}
async updateItem<UpdateDto, Entity>(
params: UpdateItem<UpdateDto>,
): Promise<Entity> {
return this.executeFnWithTryCatch(() =>
this.model.update(params) as Promise<Entity>
)
}
async softDelete(id: string): Promise<{ message: string }> {
await this.executeFnWithTryCatch(() =>
this.model.update({
where: { id },
data: { deletedAt: new Date() },
})
)
return { message: `${this.entityName} deleted successfully` }
}
async exists(params: Exists): Promise<boolean> {
const found = await this.model.findFirst({
...params,
where: { ...params.where, deletedAt: null },
})
return !!found
}
private async executeFnWithTryCatch<Result>(
fn: () => Promise<Result>,
): Promise<Result> {
try {
return await fn()
} catch (err) {
this.errorService.handle(err)
}
}
}
Step 3: Centralize Prisma Errors
executeFnWithTryCatch passes errors to an ErrorService that translates
Prisma error codes into NestJS exceptions:
@Injectable()
export class ErrorService {
handle(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case 'P2025': throw new NotFoundException()
case 'P2002': throw new ConflictException()
case 'P2003': throw new BadRequestException()
case 'P2014': throw new BadRequestException()
}
}
throw new InternalServerErrorException()
}
}
This eliminates try/catch from every repository. More importantly:
-
updateno longer needs to check existence first — Prisma throws P2025 automatically when a record doesn't exist, andErrorServiceconverts it toNotFoundException -
createandupdatedon't need manual duplicate checks — Prisma throws P2002 on unique constraint violations, which becomesConflictExceptionautomatically
Step 4: The Domain Contract
So that services depend on an abstraction rather than a concrete class, we
create IBaseLookupRepository:
// src/shared/interfaces/base-lookup-repository.interface.ts
interface IBaseLookupRepository<
Entity,
CreateDto,
UpdateDto,
QueryParamsDto = undefined,
GetAllResponse = Entity[],
> {
getAll(params?: QueryParamsDto): Promise<GetAllResponse>
getById(id: string): Promise<Entity>
create(data: CreateDto): Promise<Entity>
update(id: string, data: UpdateDto): Promise<Entity>
delete(id: string): Promise<{ message: string }>
}
Important: the generics here must be class-level, not method-level.
If you try method-level generics
(getAll<Q, R>(params?: Q): Promise<R>), TypeScript will reject any concrete
class that tries to implement this interface. A concrete signature cannot
satisfy a generic one — the assignment direction doesn't work.
Step 5: Using It in Concrete Repositories
With the base class ready, a simple repository looks like this:
@Injectable()
export class StatusRepository
extends BaseRepository<Prisma.StatusDelegate>
implements IBaseLookupRepository<Status, CreateStatusDto, UpdateStatusDto> {
constructor(prisma: PrismaService, errorService: ErrorService) {
super(errorService, prisma.status, 'Status')
}
async getAll(): Promise<Status[]> {
return this.findAll<Status>({ orderBy: { name: 'asc' } })
}
async getById(id: string): Promise<Status> {
return this.findItem<Status>({ where: { id } })
}
async create(data: CreateStatusDto): Promise<Status> {
return this.insert<CreateStatusDto, Status>({ data })
}
async update(id: string, data: UpdateStatusDto): Promise<Status> {
return this.updateItem<UpdateStatusDto, Status>({ where: { id }, data })
}
async delete(id: string): Promise<{ message: string }> {
return this.softDelete(id)
}
}
The repository now only has domain-specific logic: ordering, includes,
duplicate name validation. Zero repeated infrastructure.
Step 6: BaseService
The same pattern moves up to the service layer. One important detail: getAll
might return a plain list or a paginated response. We solve this with a fifth
generic that defaults to Entity[]:
// src/shared/services/base/base.service.ts
abstract class BaseService<
Entity,
CreateDto,
UpdateDto,
ParamsDto = undefined,
GetAllResponse = Entity[],
> {
constructor(
private readonly repository: IBaseLookupRepository<
Entity,
CreateDto,
UpdateDto,
ParamsDto,
GetAllResponse
>,
) {}
async getAll(params?: ParamsDto): Promise<GetAllResponse> {
return this.repository.getAll(params)
}
async getById(id: string): Promise<Entity> {
return this.repository.getById(id)
}
async create(data: CreateDto): Promise<Entity> {
return this.repository.create(data)
}
async update(id: string, data: UpdateDto): Promise<Entity> {
return this.repository.update(id, data)
}
async delete(id: string): Promise<{ message: string }> {
return this.repository.delete(id)
}
}
Service without pagination — just the constructor:
@Injectable()
export class StatusService extends BaseService<
Status,
CreateStatusDto,
UpdateStatusDto
> {
constructor(repo: StatusRepository) {
super(repo)
}
}
Paginated service — pass all 5 types:
@Injectable()
export class ProductService extends BaseService<
Product,
CreateProductDto,
UpdateProductDto,
ProductQueryDto,
ProductPaginatedResponse
> {
constructor(repo: ProductRepository) {
super(repo)
}
}
Service with extra methods — inject the typed repo and also pass it to
super. This way you access repo-specific methods (not on
IBaseLookupRepository) without needing protected on the base class:
@Injectable()
export class CategoryService extends BaseService<
Category,
CreateCategoryDto,
UpdateCategoryDto
> {
constructor(private repo: CategoryRepository) {
super(repo)
}
async getBySlug(slug: string): Promise<Category> {
return this.repo.getBySlug(slug)
}
}
What Not to Abstract
Modules with a completely custom API don't need to inherit from BaseService.
If a module has link, unlink, activate, deactivate instead of
create/update, the abstraction doesn't help — and forcing it would be worse.
Three similar methods are better than a premature abstraction.
Final Results
| Before | After |
|---|---|
| 5 repeated methods per repository | 0 — inherited from base |
Manual try/catch on every operation |
0 — centralized ErrorService |
Existence check before update
|
Not needed — P2025 becomes NotFoundException |
| New CRUD module: ~30 min | New CRUD module: ~5 min |
Domain code ended up where it belongs: in concrete repositories and services.
Infrastructure lives in the base and no longer needs attention.
Top comments (0)