I'm building RunHop in public — a social + event platform for running races, built on NestJS.
Today I built the Registration module, and the most interesting part wasn't the business logic — it was realizing that Prisma methods fail in fundamentally different ways, and you need to handle each one differently.
The Module
Registration enforces three rules:
- The parent event must be PUBLISHED
- The race must have capacity
- A user can only register once per race (unique constraint on
userId + raceId)
The service has six methods: create, cancel, confirm, listByRace, listByUser, and findById. The controller exposes five endpoints, including an empty-body POST — raceId comes from the URL, userId comes from the JWT.
The Problem: Three Failure Modes
Here's where it got interesting. I initially treated all Prisma errors the same way. That's wrong.
findUnique returns null
async findById(id: string) {
return this.prisma.registration.findUnique({
where: { id },
});
}
When the record doesn't exist, findUnique returns null. It does not throw. If you wrap this in a try/catch for P2025, that catch block will never fire.
For cancel, where I need the registration to check ownership, the correct approach is a null guard:
async cancel(registrationId: string, userId: string) {
const registration = await this.prisma.registration.findUnique({
where: { id: registrationId },
});
if (!registration) {
throw new NotFoundException('Registration not found.');
}
if (registration.userId !== userId) {
throw new ForbiddenException('You can only cancel your own registration');
}
return await this.prisma.registration.update({
where: { id: registrationId },
data: { status: 'CANCELLED' },
});
}
update throws P2025
async confirm(registrationId: string) {
try {
return await this.prisma.registration.update({
where: { id: registrationId },
data: { status: 'CONFIRMED' },
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new NotFoundException('Registration not found.');
}
throw error;
}
}
When update can't find a matching record, it throws PrismaClientKnownRequestError with code P2025. This is different from findUnique — you need the try/catch here.
create throws P2002
try {
return await this.prisma.registration.create({
data: { userId, raceId },
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException('You are already registered for this race.');
}
throw error;
}
When a unique constraint is violated, create throws P2002. This is how the database enforces "one registration per user per race" — the schema has a @@unique([userId, raceId]), and Prisma surfaces the violation as a typed error.
Status Filtering
I added optional status filtering to listByUser. The approach: a registrationStatus map keyed by string, validated with the in operator, then spread conditionally into the Prisma query:
const registrationStatus: Record<string, RegistrationStatus> = {
PENDING: RegistrationStatus.PENDING,
CONFIRMED: RegistrationStatus.CONFIRMED,
CANCELLED: RegistrationStatus.CANCELLED,
};
async listByUser({ userId, cursor, take = 20, status }: { ... }) {
if (!!status && !(status.toUpperCase() in registrationStatus)) {
throw new BadRequestException('Invalid registration status.');
}
const args: Prisma.RegistrationFindManyArgs = {
take,
where: {
userId,
...(status && {
status: registrationStatus[status.toUpperCase()],
}),
},
orderBy: { registeredAt: 'asc' },
};
// ... cursor logic, return paginated result
}
Case-insensitive, type-safe, and the query only includes the status filter when a value is provided.
Testing With Realistic Data
The e2e test creates 30 registrations in a loop, using i % 3 to distribute statuses:
for (let i = 0; i < 30; i++) {
// create event, race, publish, register...
if (i % 3 === 0) {
// confirm registration
}
if (i % 3 === 1) {
// cancel registration
}
// i % 3 === 2 remains PENDING
}
This gives 10 of each status without separate setup blocks. Clean, predictable, and easy to assert against with ?status=confirmed&limit=5.
Takeaway
Prisma's error handling isn't uniform. findUnique returns null, update/delete throw P2025, create throws P2002 on constraint violations. Treating them the same leads to dead catch blocks or unhandled nulls. Match the error handling to the method's actual failure mode.
Top comments (0)