DEV Community

Cover image for Calling a Service Inside prisma.$transaction Doesn't Make It Atomic — Here's the Bug That Taught Me That
Mhamd Ghanoum
Mhamd Ghanoum

Posted on

Calling a Service Inside prisma.$transaction Doesn't Make It Atomic — Here's the Bug That Taught Me That

I was fixing a real bug in a delivery backend I'm building: a customer's order could get created in Postgres even when something downstream failed, leaving an order with no event history behind it. The fix looked simple. Wrap the whole thing in
prisma.$transaction:

const order = await this.prisma.$transaction(async (tx) => {
  const newOrder = await tx.order.create({ data: { ... } });

  await this.eventLogService.recordEvent({
    entityType: 'ORDER',
    entityId: newOrder.id,
    eventType: EVENTS.ORDER.CREATED,
    payload: { orderId: newOrder.id, status: OrderStatus.PENDING_VENDOR_CONFIRMATION },
  });

  return newOrder;
});
Enter fullscreen mode Exit fullscreen mode

I ran it, broke the event-logging step on purpose to test the rollback, and the order still got committed. The transaction did nothing.

Why

$transaction(async (tx) => {...}) opens one database connection and starts a transaction on it. The tx object you get inside the callback is bound to that specific connection. Every query run through tx participates in the same transaction.

My EventLogService.recordEvent method didn't use tx. It used this.prisma — its own injected Prisma client, a completely different connection:

async recordEvent(params) {
  const event = await this.prisma.eventLog.create({ ... }); // not tx — its own connection
  ...
}
Enter fullscreen mode Exit fullscreen mode

Nesting the call inside $transaction changes nothing here. Prisma has no way to know "we're inside a transaction right now, please route all queries through it." The only thing that determines whether a query participates in a transaction is which client object actually executed it. Since recordEvent ran on its own connection, its insert committed instantly and independently, with no awareness that anything else was "inside" a transaction around it.

The fix
Every method that needs to be transaction-capable has to accept an optional transaction client and fall back to the default when none is passed:

async recordEvent(params, tx?: Prisma.TransactionClient) {
  const client = tx ?? this.prisma;
  const event = await client.eventLog.create({ data: params });
  ...
}
Enter fullscreen mode Exit fullscreen mode

Then the caller passes its own tx through explicitly:

await this.eventLogService.recordEvent(params, tx);

Enter fullscreen mode Exit fullscreen mode

Now recordEvent's insert runs on the same connection, inside the same transaction, as everything else. If anything later in that transaction throws, Postgres rolls back the whole connection's uncommitted work — including this insert — because it genuinely was part of the same unit of work, not just nested inside it syntactically.

I retested the same failure scenario. This time, the order didn't get created either. Rollback worked as a single unit, order and event together.

The part worth remembering
Code structure and transaction membership are two different things. A function call being lexically inside a $transaction block tells you nothing about which database connection it actually used. If you're composing transactional logic across services in Prisma — or any ORM with a similar explicit-client model — every service in that chain needs to accept and thread through the transaction client deliberately. There's no implicit "ambient transaction" you can rely on.

The honest cost: this makes service signatures a bit noisier — an extra optional parameter on every method that might need to participate in a caller's transaction. I think that's a fair trade for not silently losing atomicity on exactly the kind of multi-step write where you'd assume you had it.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.