I'm building RunHop in public, a social + event platform for running races. Today I worked on the Reactions module: likes on posts.
The module itself was straightforward:
POST /posts/:id/likesDELETE /posts/:id/likes- a
ReactionServicethat talks to Prisma'spostLikemodel - unit tests and e2e coverage for the happy path and duplicate-like path
The interesting bug was in the duplicate-like flow.
The Service Looked Correct
I had this shape in src/domain/social/reaction/reaction.service.ts:
async like(postId: string, userId: string) {
const post = await this.postService.findById(postId);
if (!post) throw new NotFoundException('Post not found');
try {
return this.prisma.postLike.create({
data: { postId, userId },
});
} catch (error) {
if (
error instanceof PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException('Duplicate like');
}
throw error;
}
}
At a glance it feels fine. There's a try/catch, Prisma throws P2002 for a unique constraint violation, and I map it to ConflictException.
But the unit test still failed.
Why It Failed
postLike.create() returns a promise. When that promise rejects, the rejection happens asynchronously.
Because I returned the promise directly from inside the try block, the function exited before the rejection occurred. The catch block never saw the Prisma error.
So instead of this:
Prisma rejects with P2002
service catches it
Nest gets ConflictException
I got this:
Prisma rejects with P2002
promise escapes
raw Prisma error bubbles up
The Fix
The fix was to await inside the try block:
try {
return await this.prisma.postLike.create({
data: { postId, userId },
});
} catch (error) {
if (
error instanceof PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException('Duplicate like');
}
throw error;
}
This is one of the few cases where return await is exactly what you want. It keeps the rejected promise inside the try/catch boundary.
The Rest of the Module
I also added:
ownershipCheck(likeId, userId) so only the owner can delete a like unlike() mapping Prisma P2025 to NotFoundException
an e2e test that does the full flow:
register
create post
like
duplicate like -> 409
unlike
unlike again -> 404
One design detail I still want to revisit: the delete route is DELETE /posts/:id/likes, but the :id there is currently a like id, not a post id. It works, but the route shape is carrying two meanings between create and delete.
Takeaway
The useful reminder from this session:
try/catch only catches what stays inside it.
If you need to translate async database errors into framework exceptions, you need the rejected promise to be awaited inside the try block.











