A transaction wrapping multiple database operations guarantees that the data ends up consistent — either everything commits, or everything rolls back. What it says nothing about is what the screen shows the person using the application while that transaction is in progress. A reader's question on a previous article about the Unit of Work pattern surfaced exactly this gap, and it's worth examining carefully, because it's a genuinely separate concern from transactional correctness at the database level.
The Question That Prompted This
On a salary batch processing screen using a shared transaction across two save steps, a reader asked: does the UI show a single save failure when the second write fails, or can it make the first step look successful before the rollback happens? For anyone reconciling payroll later, that distinction matters enormously.
Why This Is a Genuinely Different Problem
The database-level guarantee is straightforward and, if built correctly, reliable:
csharp
var batchResult = InsertBatchRecord(batchModel, connection, transaction);
if (batchResult.Success)
{
var updateResult = UpdateRelatedRecords(batchResult.GeneratedId, relatedModel, connection, transaction);
if (updateResult.Success)
{
transaction.Commit();
}
else
{
transaction.Rollback();
}
}
If UpdateRelatedRecords fails, transaction.Rollback() undoes everything, including the batch insert that technically succeeded moments earlier. The database ends up correct either way.
But consider what happens if the code also updates the UI at each step along the way:
csharp
var batchResult = InsertBatchRecord(batchModel, connection, transaction);
if (batchResult.Success)
{
DisplayMessage("Step 1: Batch record saved successfully!"); // shown immediately
var updateResult = UpdateRelatedRecords(batchResult.GeneratedId, relatedModel, connection, transaction);
if (!updateResult.Success)
{
transaction.Rollback();
DisplayMessage("Step 2 failed. Transaction rolled back.");
}
else
{
transaction.Commit();
DisplayMessage("Both steps completed successfully.");
}
}
The person watching this screen sees "Step 1: Batch record saved successfully!" before Step 2 has even run. If Step 2 then fails and the rollback executes, the database correctly ends up with no batch record at all — but the user already saw, and may have acted on, a success message for data that no longer exists. Even if a second message eventually clarifies that the transaction rolled back, that first message was real and displayed, and if the person only glanced at the screen once, or the message scrolled past, or they're reviewing a screenshot or log entry taken at that moment, they have genuine evidence of a "success" that the database has since erased.
Why This Matters Specifically for Reconciliation
The reader's framing — "for whoever is reconciling payroll" — points at exactly why this distinction has real consequences. Reconciliation often happens after the fact, using whatever evidence is available: screenshots, support tickets, log entries, or someone's memory of what they saw on screen. If the UI displayed an intermediate success message that the database later invalidated through a rollback, that piece of evidence is now actively misleading anyone trying to reconstruct what actually happened. A support ticket referencing "the batch was saved, I saw the confirmation" becomes hard to reconcile against a database that shows no such record — not because anyone made an error, but because the UI communicated something that was only momentarily, provisionally true.
The Safer Pattern: One Final Status, Not Per-Step Messages
The straightforward fix is to withhold any success messaging until the entire transaction has actually committed:
csharp
var batchResult = InsertBatchRecord(batchModel, connection, transaction);
bool overallSuccess = false;
if (batchResult.Success)
{
var updateResult = UpdateRelatedRecords(batchResult.GeneratedId, relatedModel, connection, transaction);
if (updateResult.Success)
{
transaction.Commit();
overallSuccess = true;
}
else
{
transaction.Rollback();
}
}
else
{
transaction.Rollback();
}
DisplayMessage(overallSuccess
? "Batch processed successfully."
: "Batch processing failed. No changes were saved.");
No intermediate messages are shown at all. The person only sees one final status, generated only after the commit or rollback has genuinely happened. This removes the possibility of a UI message ever describing a state that a subsequent rollback later invalidates — the message is only ever displayed once the underlying data is in its final, permanent state.
When Progress Indicators Are Still Needed
Some workflows genuinely need to show progress during a multi-step operation, particularly if steps take noticeable time. In those cases, progress indicators should communicate activity, not completion — something like "Processing step 1 of 2..." rather than "Step 1 saved successfully," since the former makes no claim about permanence that a rollback could later contradict, while the latter does.
A Practical Review Question
When reviewing any multi-step save operation, it's worth asking not only "does the database stay consistent on failure," but separately, "does any user-facing message get shown before the transaction actually resolves, and could that message become misleading if a later step fails?" These are two different checks, and passing the first doesn't guarantee passing the second — a perfectly correct transaction can still sit behind a UI that tells a story the database doesn't ultimately support.
Takeaway
Transactional correctness at the database level and accurate communication at the UI level are related but distinct problems. A shared transaction with proper commit and rollback logic guarantees the data ends up consistent, but it says nothing about what gets displayed to a user in the moments before that resolution happens. The safer default is to withhold any success messaging until the entire operation has genuinely committed, so that whatever the user sees — and whatever gets captured in a screenshot, log, or support ticket — accurately reflects the database's final, permanent state, rather than a provisional condition a rollback might later erase.
Top comments (1)
Separating progress from committed success is an important rule. There is one more ambiguous outcome after Commit: the commit can succeed while the HTTP response or UI notification is lost. The user sees a timeout and may retry even though the database already changed. For longer operations, I prefer returning a stable operation ID, making the command idempotent, and exposing a status endpoint backed by durable state. The UI can then say “submitted” or “processing” and reconcile against the authoritative result. That adds an operation record or outbox, but it also provides reliable evidence when transport delivery and database outcome disagree.