DEV Community

Paulo W. A. Ferreira
Paulo W. A. Ferreira

Posted on

How I Stopped Writing the Same 5 Methods in Every NestJS Repository

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

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:

  • findMany always with deletedAt: null (soft delete pattern)
  • findFirst always throwing NotFoundException if nothing is found
  • create and update via Prisma
  • try/catch mapping 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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

This eliminates try/catch from every repository. More importantly:

  • update no longer needs to check existence first — Prisma throws P2025 automatically when a record doesn't exist, and ErrorService converts it to NotFoundException
  • create and update don't need manual duplicate checks — Prisma throws P2002 on unique constraint violations, which becomes ConflictException automatically

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

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

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

Service without pagination — just the constructor:

@Injectable()
export class StatusService extends BaseService<
  Status,
  CreateStatusDto,
  UpdateStatusDto
> {
  constructor(repo: StatusRepository) {
    super(repo)
  }
}
Enter fullscreen mode Exit fullscreen mode

Paginated service — pass all 5 types:

@Injectable()
export class ProductService extends BaseService<
  Product,
  CreateProductDto,
  UpdateProductDto,
  ProductQueryDto,
  ProductPaginatedResponse
> {
  constructor(repo: ProductRepository) {
    super(repo)
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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)