Loading...
Loading...
Comprehensive Prisma repository implementation patterns for NestJS. This skill should be used when implementing repositories that extend BaseRepository or use Prisma delegates.
npx skill4agent add allenlin90/eridu-services repository-pattern-nestjsBaseRepository<T, C, U, W>[!IMPORTANT] Why Extend BaseRepository?
- Standardization: Consistent CRUD interface across all repositories
- Soft Delete: Automatic
filtering in base methodsdeletedAt: null- Type Safety: Generic types ensure compile-time correctness
- Reusability: Inherit common methods (create, findOne, update, etc.)
- Maintainability: Bug fixes in BaseRepository benefit all repositories
[!NOTE] ModelWrapper — Current Implementation (Planned for Simplification) All existing repositories use aclass that bridgesModelWrappergenerics to Prisma delegates. This wrapper adds no business value and will be simplified in a future phase (Phase 4) to inject PrismaService directly. For now, follow this pattern to stay consistent with the existing codebase.BaseRepository
import { BaseRepository, IBaseModel } from '@/lib/repositories/base.repository';
// 1. Define Wrapper (bridges BaseRepo generics to Prisma Delegate)
class UserModelWrapper implements IBaseModel<User, Prisma.UserCreateInput, Prisma.UserUpdateInput, Prisma.UserWhereInput> {
constructor(private readonly delegate: Prisma.UserDelegate) {}
create(args: any) { return this.delegate.create(args); }
findFirst(args: any) { return this.delegate.findFirst(args); }
findMany(args: any) { return this.delegate.findMany(args); }
update(args: any) { return this.delegate.update(args); }
delete(args: any) { return this.delegate.delete(args); }
count(args: any) { return this.delegate.count(args); }
}
// 2. Implement Repository
@Injectable()
export class UserRepository extends BaseRepository<
User,
Prisma.UserCreateInput,
Prisma.UserUpdateInput,
Prisma.UserWhereInput
> {
constructor(private readonly prisma: PrismaService) {
super(new UserModelWrapper(prisma.user));
}
}// Find by UID — use findOne({ uid, deletedAt: null }) from BaseRepository
// instead of adding a redundant findByUid wrapper per repository.
// Only add findByUid if it has additional logic (e.g., includes, scoping).
async findByUid(uid: string, include?: Prisma.UserInclude): Promise<User | null> {
return this.model.findFirst({
where: { uid, deletedAt: null },
...(include && { include }),
});
}
// Find with Relations (Type-safe)
async findByUidWithProfile(uid: string): Promise<User & { profile: Profile } | null> {
return this.model.findFirst({
where: { uid, deletedAt: null },
include: { profile: true },
});
}[!WARNING] Do NOT implementin repositories. The Controller-Checks Pattern requires services to returnfindByUidOrThrowand controllers to callnull. Throwing in the repository bypasses this pattern and couples the data layer to HTTP semantics. UseensureResourceExists()and let the controller handle the 404.findOne({ uid, deletedAt: null })
// The repo exists as a file but is only referenced inside the module
@Injectable()
export class TaskTargetRepository extends BaseRepository<TaskTarget, ...> {
async findByShowId(showId: bigint): Promise<TaskTarget[]> { ... }
async findByTaskId(taskId: bigint): Promise<TaskTarget[]> { ... }
}
// The service is what gets exported
@Injectable()
export class TaskTargetService extends BaseModelService {
async create(...args) { return this.repo.create(...args); }
async findByShowId(showId: bigint) { return this.repo.findByShowId(showId); }
}async findPaginated(params: {
skip?: number;
take?: number;
name?: string;
uid?: string;
includeDeleted?: boolean;
studioUid?: string;
orderBy?: 'asc' | 'desc';
}): Promise<{ data: TaskTemplate[]; total: number }> {
const { skip, take, name, uid, includeDeleted, studioUid, orderBy } = params;
// Repository builds Prisma where clause
const where: Prisma.TaskTemplateWhereInput = {};
if (!includeDeleted) {
where.deletedAt = null;
}
if (name) {
where.name = { contains: name, mode: 'insensitive' };
}
if (uid) {
where.uid = { contains: uid, mode: 'insensitive' };
}
if (studioUid) {
where.studio = { uid: studioUid };
}
const [data, total] = await Promise.all([
this.model.findMany({
skip,
take,
where,
orderBy: orderBy ? { createdAt: orderBy } : undefined,
}),
this.model.count({ where }),
]);
return { data, total };
}updateWithVersionCheck()import { VersionConflictError } from '@/lib/errors/version-conflict.error';
import { PRISMA_ERROR } from '@/lib/errors/prisma-error-codes';
async updateWithVersionCheck(
where: Prisma.TaskTemplateWhereUniqueInput & { version?: number },
data: Prisma.TaskTemplateUpdateInput,
include?: Prisma.TaskTemplateInclude,
): Promise<TaskTemplate> {
try {
return await this.prisma.taskTemplate.update({
where: { ...where, deletedAt: null },
data,
...(include && { include }),
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PRISMA_ERROR.RecordNotFound && where.version) {
const existing = await this.findOne({ uid: where.uid, deletedAt: null });
if (!existing) {
throw error; // Actually not found
}
throw new VersionConflictError(
'Task template version is outdated',
where.version,
existing.version,
);
}
}
throw error;
}
}BaseRepositoryIBaseModelfindOne({ uid, deletedAt: null })findByUidfindByUidfindByUidOrThrowensureResourceExists()deletedAt: nullPromise.allnullensureResourceExistsPrisma.GetPayloadupdateWithVersionCheckVersionConflictErrorfindPaginatedfindFirstdeletedAt