DEV Community

Cover image for Prisma Errors Aren't All the Same: Building a Registration Module in NestJS
Rhico
Rhico

Posted on

Prisma Errors Aren't All the Same: Building a Registration Module in NestJS

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:

  1. The parent event must be PUBLISHED
  2. The race must have capacity
  3. 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 },
    });
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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)