Transaction

Concept

A mechanism that binds several data changes into one unit, so either all of them commit or none of them happened.

Why it exists

Work that looks like a single action splits into several separate steps by the time it reaches the data. Upgrading a plan is one write recording the payment and one write changing the plan value. To a person it's a single button press; to the store underneath it's two separate events.

If something goes wrong between them, a half-finished state is left behind. That happened at Pagedeck. The payment provider approved the charge and the next line threw an error, leaving fourteen accounts a month that had paid while their plan still read free. The customer had paid and the product hadn't opened anything. Support took the list and fixed them one at a time, and somewhere in that process confused an already-processed payment and opened one account twice.

Writing the lines in the right order doesn't solve this, because code cannot handle the case where the server dies between line one and line two, no matter how the lines are arranged. A transaction closes that gap on the database side, allowing only two endings: everything in the bundle commits, or nothing from it remains. Under database — the system that stores data and lets you retrieve it — this belongs to the consistency group, the one that keeps values from being left half-written.

How it works

There are three commands: begin, commit, and roll back.

  1. Declare the beginning. Changes after this don't take effect immediately; they accumulate provisionally. Anybody else reading the same data during that window still sees the pre-change values.
  2. Commit. The accumulated changes land all at once and become visible to everybody from that moment. After this point there's no going back.
  3. Roll back. The program may call it explicitly on hitting an error, and the database does it on its own if the connection drops or the server dies. That automatic rollback, the one nobody has to write, is the actual value of a transaction.

The four properties such a bundle has to honor go by the name ACID: all or nothing, never ending in a state that breaks the rules, not getting tangled with other bundles running at the same time, and surviving a power loss once committed.

Concurrency is where the trouble starts. When two people try to change the same row at once, the database makes one of them wait, which is called a lock. Shorter locks are better, without exception. When two transactions each wait on what the other holds, both stop forever — a deadlock — and the database resolves it by forcibly rolling one back.

So where the boundary goes is the real design work. The rule is to keep it short. Wait for a payment provider's response inside a transaction and the lock is held for those seconds, and on a day the provider is slow every request touching that data backs up behind it. Calls out to anything external move outside the boundary, and only writes to your own store stay inside it.

In a structure split across services, there's no binding them into one transaction. With the payment service and the account service each holding their own database, a failure on one side leaves no way to roll back the other. What gets built instead is a compensating step that learns about the failure afterward and performs the reverse action — sending a cancellation request, for example — and that request has to be idempotent, giving the same result however many times it's sent.

Where it shows up

Anywhere money, inventory or permissions are involved, this is present almost without exception. A half-written value has to be corrected by hand, and correcting things by hand introduces mistakes of its own.

Three places at Pagedeck are bound together this way: the plan upgrade that pairs the payment record with the plan change, moving a document to another team, and inviting a teammate. Moving a document is a write at the new location and a delete at the old one, so unbound it produces documents that exist in two places or in none. An invite is a write creating the account and a write attaching permissions; unbound it leaves an account with no permissions at all, which logs in and shows nothing.

There are also places to leave it out: read-only queries, and record-keeping writes that can simply be retried. Wrap an access log in a transaction and the only thing it accomplishes is extending the lock time of the real work.

With a NoSQL product, one not bound to table structure, check the support range first. Some guarantee all-or-nothing within a single record and don't guarantee it across records. In a relational database, which stores data as tables and relations, it's usually there by default and needs no thought.

How it sounds at work

"These two writes need to be one transaction. Die in between and only the payment survives."

Setting the boundary of the bundle before it gets written.

"Don't call an external API inside a transaction. That's a three-second lock."

Pointing out a boundary that was drawn too wide.

"We're deadlocking twice a day. I think we need to align the lock order."

Proposing a fix for two bundles that keep waiting on each other.

At Pagedeck, Theo raises this and Sofia hears the results, because when a half-finished state is left behind it's support that cleans up after it. After the month of fourteen manual fixes, any change to payment code carries a step confirming where the boundary was drawn.

The way to confirm it isn't reading the code. Throw an error deliberately in the middle of the bundle and look at what state the data is left in afterward.

What people get wrong

The most common position is that writing the lines in order makes this unnecessary. Two statements sitting side by side look certain to both run. But there is a gap between them where the server can die, and code cannot fill that gap. The problem also stays hidden in normal conditions: in code that runs ten thousand times a day, landing in that gap happens a dozen times a month, so it looks like no problem at all right up until users grow and it grows in proportion with them.

The other frequent belief is that wrapping things in a transaction makes them safe, which invites the illusion that wrapping more widely is safer. What actually happens is that a wider boundary means longer locks, and longer locks mean every other request touching that data queues up. It's a trade of making one request safe for making everything slower, and at the extreme end deadlocks become a routine occurrence. So the boundary gets revised by narrowing rather than widening: keep only the writes to your own store inside and move everything else out.

Where to go next

  • Database

    see which group this mechanism belongs to

  • ACID

    a closer look at the four properties the bundle has to honor

Last updated 2026-09-16