0% прочитано

When One Transaction Is Better Than an Event

An event is useful when later work can react to an already completed fact. But when two changes together form one valid operation and the intermediate state is invalid, a single transaction is often the safer boundary.

5 жовтня 2026 р. 8 хв читанняLaravel

Events are useful for separating responsibilities across a system.

But the fact that an event can be introduced does not mean an operation should be split into two parts.

Sometimes the second change is not a reaction to the first. It is a mandatory part of the same operation.

In that case, the state between the two changes may be invalid and should never become visible.

The main question I ask is: does the system remain correct if the second action happens later?

If not, one transaction is often a better boundary than an asynchronous event.

If the first change is only valid when the second change also happens, I do not treat the second change as an independent event handler.

I Start by Looking at the Intermediate State

The intermediate state defines the boundary
Step A   |   vintermediate state   |   vStep B  Question: can the system remainbetween A and Band still be correct?

This is the simplest test for me.

If the system is already fully valid after the first change, the second action may be an independent reaction.

If the data violates an invariant until the second change happens, splitting the two creates a dangerous gap.

An Event Creates a Gap Between Two Changes

Two separate persistence points
Transaction 1 change Acommit   |   vevent   |   vqueue   |   vhandler   |   vTransaction 2

Milliseconds, seconds, or much longer may pass between the two transactions.

The handler may be delayed, retried, or temporarily fail.

If the state after the first commit is already valid, that can be perfectly fine.

But if that state should never exist without the second change, the design moves a consistency problem into the queue.

One Transaction Keeps the Invalid Intermediate State Invisible

PHP - Simplified atomic change
DB::transaction(function () use (    $firstId,    $secondId,): void {    $first = FirstRecord::query()        ->whereKey($firstId)        ->lockForUpdate()        ->firstOrFail();     $second = SecondRecord::query()        ->whereKey($secondId)        ->lockForUpdate()        ->firstOrFail();     $first->applyChange();    $second->applyRelatedChange();

In this version, both changes have one completion point.

Either both become persistent or neither is visible as a completed operation.

An intermediate state may exist during execution, but it does not become a separate committed system state.

An Event Is Not a Replacement for Atomicity

An event is good at answering "who else wants to react to an already completed fact?".

A transaction answers a different question: "which changes must become visible together?".

These mechanisms do not compete and do not replace each other.

The problem begins when an event is used not to react to a fact, but to finish creating the fact itself.

I Imagine the Second Part Never Runs

Test the case where the second action never happens
Step A completed Step B did not run and neverwill run  Is the systemstill valid?  YES-> an event may be fine  

I deliberately test a stronger case than a short delay: the second action never happens at all.

If the data is still valid, asynchronous reaction may be acceptable.

If the system remains in a state that must later be repaired, the second action is probably not just an event reaction.

Other Requests May Observe the Intermediate State

The intermediate state becomes observable system state
Request A change Acommit   |   |--------- gap --------|                         |                      change B  Request B         read          |          vsees state

This matters beyond the queue itself.

Another HTTP request, background job, or report may read the data exactly between the two changes.

If that state is invalid, it can already influence other system decisions.

A single transaction prevents that state from becoming a separate committed version of the data.

Splitting with an Event Introduces a Separate Recovery Problem

Once the second part runs separately, several new questions appear.

  • What happens if the handler fails?
  • How many times should it retry?
  • Is retrying safe?
  • How do we find half-completed operations?
  • Who owns recovery?

Sometimes that complexity is justified. I do not introduce it when one local transaction naturally protects the rule.

One Transaction Is Not Always the Answer Either

This principle does not mean everything should run synchronously.

If the second action is a notification, analytics update, projection refresh, or another reaction that is not required for the main state to be valid, an event may be better.

A normal local database transaction also cannot atomically control several independent external systems.

I choose one transaction not because synchronous code is inherently better, but because the data model sometimes requires shared atomicity.

One Transaction Is Especially Natural Within One Storage Boundary

The simplest case is when both related changes live in the same database.

If they jointly protect one invariant, the database already provides a mechanism for committing them atomically.

In that situation, inserting an event between two local writes can add complexity to something a transaction solves directly.

Concurrent Changes May Require Locking as Well as a Transaction

A transaction alone does not automatically guarantee the ordering needed between concurrent requests.

If the decision depends on current record state, I re-read it inside the transaction and use lockForUpdate() when needed.

The transaction defines the atomic boundary, while locking can protect the critical decision from concurrent modification.

An Event May Still Be Useful After the Atomic Change Completes

PHP - Complete the invariant first, then publish the fact
DB::transaction(function () use ($id): void {    $record = Record::query()        ->whereKey($id)        ->lockForUpdate()        ->firstOrFail();     $record->applyPrimaryChange();    $record->applyRequiredRelatedChange();     $record->save();}); event(    new OperationCompleted($id),);

Choosing a transaction does not mean rejecting events.

An event may be exactly right after both mandatory changes have been committed successfully.

At that point it describes a completed fact instead of trying to finish an incomplete operation.

In real code I also respect the commit boundary so the event does not announce a fact that may still roll back.

My Simple Choice Between a Transaction and an Event

Decision criterion
Is the second change requiredfor the first one to be valid? YES | vCan both be committedatomically in one storage boundary? YES | vone transaction  Is the main state already valid

What I Check Before Splitting an Operation with an Event

Before making this decision, I ask several questions:

  • Is the state after the first change valid without the second?
  • What happens if the second action never runs?
  • Can another request observe an invalid intermediate state?
  • Do both changes protect one invariant together?
  • Do both changes live inside one transactional storage boundary?
  • Is locking required because of concurrent access?
  • Does an event introduce separate retry and recovery complexity?
  • Can the event instead be published after the atomic change is complete?

I Complete the Invariant Before Separating Reactions

Events are very useful when the system already has a completed fact and several independent reactions to it.

But when two changes together form one valid operation, I do not split them merely to reduce coupling between components.

An invalid intermediate state, handler retries, and recovery mechanisms may create a much more complicated system than one local transaction.

I therefore define the atomic boundary of the data rule first.

Only after the system reaches a valid completed state do I decide which later reactions can safely move to events.

If the system should never exist between two changes, I try not to create that gap in the architecture.

An event is good at reacting to a completed fact. It should not be the mandatory second half of a fact that is not complete yet.