ADR-0031: A handler method persists or calls out, never both
Status: Accepted
Amended October 2026, see "Siblings are independent in failure" under Decision. Amended November 2026, see "No repository access, no transaction" under Decision.
Date: August 2026
Context
@handle wraps every handler invocation in a UnitOfWork
(src/protean/utils/mixins.py), and there is no way to turn that wrapping off
or change it. Since ADR-0027 the Unit of Work is one real database transaction,
so a handler that calls an external system holds row locks and a pooled
connection for the length of that call. Version (OCC) and transient retry
re-run the whole handler body, so a retry re-issues the call.
A request came in for @handle(SomeEvent, unit_of_work=False): run the handler
with no wrapping Unit of Work so it can own its own transaction boundaries and
commit sub-units independently. We worked through the cases and found that the
request named a real problem but asked for the wrong fix. Most of what it asked
for either already works or should not be built.
Three facts drove the decision:
-
The Unit of Work is lazy.
start()opens no session. The session, and on SQLAlchemy the realBEGIN, appears at the first repository access. We confirmed this on the memory and SQLite providers. A handler method that persists nothing already runs with no transaction, by accident of the implementation. -
Handler methods are already transactionally independent. For event handlers and projectors,
_dispatch_handlersloops over every matching handler method and each one gets its ownUnitOfWork. One event handled by three methods is already three independent transactions. -
They are not independent in failure. The loop propagates the first exception, so it skips the sibling methods that have not run yet.
_handlersis adefaultdict(set), so the set of skipped siblings is arbitrary. With three methods where the second fails, one sibling ran and one never did.
Spring models this problem as a propagation enum (REQUIRED, REQUIRES_NEW,
NOT_SUPPORTED, NESTED). Spring raises an error when a transactional event
listener picks a propagation that cannot work. Axon makes the Unit of Work a
lifecycle with phases and has components register work into a phase, with no
opt-out. Django offers on_commit(), atomic(durable=True) and
non_atomic_requests, and its on_commit callbacks are lost on a crash.
NServiceBus is explicit that its outbox covers outgoing messages only and does not
extend to HTTP, email, or the filesystem. Temporal records a side effect's result
in history so replay returns the recorded value instead of re-executing.
Decision
A handler method either persists or talks to the outside world. Not both.
Around it:
- No repository access, no transaction: A handler method invocation that
reaches no repository runs no transaction and holds no connection. The lazy
session becomes a stated contract rather than an incidental optimization. The
contract is about the invocation, not about the body: a handler carrying
idempotent=Truereads its(message_id, handler)marker before the body runs, and when the marker store is DB-backed that read is itself a repository access, so those invocations open a session before the body.
Amended (November 2026): when the SQL event store shares the default
database (ADR-0048), a handler method that sends
a command and reaches no repository still runs its body with no
transaction. The Unit of Work holds the command until the body returns. At
commit it opens one transaction on default, appends the command and
commits. The invocation holds a connection only for that append, and an
external call in the body runs outside any transaction. On Message DB, and
on a SQL event store with its own database_uri, the store appends the
command on its own connection when the handler sends it.
-
Both in one method: A handler that needs an external result to compute its local write does both in one method, and the transaction stays open for the call. The mitigation is the remote system's idempotency key and keeping the call short. Protean builds no mechanism for this.
-
An advisory diagnostic: A handler method that both persists and calls out will be reported. It is advisory. The previous point makes that shape legitimate sometimes, and a check that fires on correct code is one people learn to ignore. It is suppressible through the existing
suppress_checksoption. -
Siblings are independent in failure: Fact 3 above is a defect against the model this ADR states, and it gets fixed. Among one handler class's methods, two failures are excluded from that, for reasons that come from the machinery rather than the principle. An
ExpectedVersionErrorpropagates at once. Under asynchronous processing, the conflict fails the whole message and the engine redelivers it. Every sibling then runs again. Under synchronous processing nothing redelivers, so the methods after the conflict do not run for that event. (The conflicting method's own retry runs inside@handle, and it has already used all its attempts by this point.) ABaseExceptionthat is not anExceptionalso stops dispatch. Only the raised exception propagates, so nothing downstream would report the failures already collected. On both paths, Protean therefore attaches those failures to the raised exception as a note and logs them.
Amended (October 2026): across handler classes under synchronous processing,
Protean collects an ExpectedVersionError like any other failure. The
command's commit then reports it as a TransactionError with the conflict as
its __cause__. The only exclusion left across handler classes is a
BaseException that is not an Exception. The first version of this
decision let the conflict stop the drain and reach the commit unchanged.
As a result, the command's version retry ran the command again after its
writes were already saved. The retry saved those writes twice. When version
retry is enabled, @handle retries a conflict in an event handler or
projector. A process manager calls its methods without that wrapper, so its
conflict is not retried.
-
Process managers are out of scope:
BaseProcessManageroverrides_handleand runs its own loop, so it keeps the old skip-on-first-failure behavior. A process-manager method drives a state transition, so carrying on after one fails has to be reconciled with what the instance's state means afterwards. That is a separate decision, not an oversight. -
No ordering between handlers for the same event: If two reactions must happen in order, the second is reacting to what the first did, so it subscribes to an event the first raises. An ordering option would put that dependency in configuration, where nothing at the call site shows it. An event chain puts it in the code, where correlation and causation IDs already trace it and each step can be tested on its own.
The shapes this produces
Two reactions to one event, one persisting and one calling out. They are independent, run in no particular order, and neither can cancel the other:
@domain.event_handler(part_of=Order)
class OrderReactions:
@handle(OrderPlaced)
def reserve_stock(self, event):
repo = current_domain.repository_for(Inventory)
stock = repo.find_by(product_id=event.product_id)
stock.reserve(event.quantity)
repo.add(stock)
@handle(OrderPlaced)
def notify_partner(self, event):
# The channel call reaches no repository.
httpx.post(PARTNER_URL, json={"order_id": event.order_id})
When the call has to follow the write, or needs a value the write produces, the persisting method raises an event and the calling method handles that event:
@domain.event_handler(part_of=Order)
class OrderConfirmation:
@handle(OrderPlaced)
def confirm(self, event):
repo = current_domain.repository_for(Order)
order = repo.get(event.order_id)
order.confirm() # raises OrderConfirmed
repo.add(order)
@domain.event_handler(part_of=Order)
class PartnerNotifier:
@handle(OrderConfirmed)
def notify_partner(self, event):
httpx.post(
PARTNER_URL,
json={"order_id": event.order_id, "confirmed_at": event.confirmed_at},
)
OrderConfirmed goes to the outbox inside the confirming transaction and is
published once that transaction commits, so notify_partner runs after the write
is durable. The value it needs travels on the event.
The shape to avoid is one method doing both, where the external call runs inside the transaction that the repository access opened:
@handle(OrderPlaced)
def confirm_and_notify(self, event):
repo = current_domain.repository_for(Order)
order = repo.get(event.order_id) # transaction opens here
order.confirm()
repo.add(order)
httpx.post(PARTNER_URL, json={"order_id": event.order_id}) # holds locks
When the call comes first and the write needs its result
Some flows run the other way: make the call, then save what it returned. A notification dispatcher sends a message through a channel and then records whether the send worked. The record step needs the send's result, so it cannot come first. The sending method has no aggregate to raise an event from, so it cannot pass the result on through an event either.
The sending method calls the channel, then hands the write to a command with
domain.process(command, asynchronous=True):
@domain.event_handler(part_of=Notification)
class NotificationDispatcher:
@handle(NotificationDispatchStarted)
def send(self, event):
# The channel call reaches no repository.
outcome = channel.send(event.recipient, event.body)
current_domain.process(
RecordDispatchResult(
notification_id=event.notification_id,
status="SENT" if outcome.ok else "FAILED",
),
asynchronous=True,
)
In a domain with command_processing = "sync", a call to
domain.process(command, asynchronous=True) inside an active unit of work
stores the command and returns None when the command has a registered
handler. A command with no registered handler still goes to the engine. The command is
handled after the caller's unit of work commits, in its own unit of work with
its own version retry. The same applies whoever opened the unit of work: a
handler, a @use_case method, a with UnitOfWork(): block or an explicit
UnitOfWork.start(). It applies under
both event_processing settings. This is how sync event processing already runs
event handlers (ADR-0016), and asynchronous=True already means "outside my
transaction". Called outside any unit of work, the same call still handles the
command at once and returns its result.
So when a version conflict occurs on the record step, Protean retries the
record step alone. The send runs once. When the record step still fails, or its
deadline passes before it runs, the caller's commit raises TransactionError.
It never raises ExpectedVersionError, which would make the caller's own
version retry run the send again. A rollback of the caller means the command
never runs.
A TransactionError from the record step still reaches whatever runs the
caller. Under event_processing = "async", the engine then delivers the event
again, up to its retry limit, and each delivery sends again. Protean leaves
those repeats to the remote system: pass the channel an idempotency key, and
the remote system drops a repeated send.
Protean stores every command it handles itself, at once or after the commit,
with asynchronous = False. The engine's command subscription skips such a
command, so a running engine does not handle it a second time. The cost is the
same one sync events carry: if the process stops after the caller commits and
before the command runs, the stored command is not handled.
With an idempotency_key, the command records its outcome when its handler
runs. A repeat call after a success returns the cached result. A repeat call
after a failure stores the command and defers it again.
Two other answers were rejected. A version retry scoped to one step would make atomicity depend on the order in which the method reaches its repositories. A "requires new" block that commits on its own would be new public API, and it brings back the nesting questions this ADR avoids.
No new API is added. Everything else the request asked for already exists: the
outbox for outbound messages, idempotent=True for redelivery, event chains for
sequencing, and separate handler classes for full independence with their own
subscriptions and checkpoints.
The lazy Unit of Work already behaves as the guarantee requires, and a regression test will check that behavior. The advisory diagnostic does not exist yet. The sibling-skipping defect is fixed for event handler and projector methods, and for sibling handler classes under synchronous dispatch, so sync now matches async. In both processing modes, "independent reactions to one fact are independent in failure" now holds for event handlers and projectors, apart from the two exclusions under "Siblings are independent in failure". The same sibling-skipping defect remains at two sites (process managers' own dispatch loop and broker subscribers) and is tracked separately.
Consequences
Positive:
- The rule fits in one sentence and is checkable, both by a reader looking at a single method and by a static rule.
- The public surface does not grow. There is no flag, phase, scope, or journal to learn, and no new interaction with retry, idempotency, or nesting to reason about.
- Sibling handler methods become genuinely independent once the sibling-skipping defect is fixed, matching the model users already have of them.
- ADR-0027 is untouched. Nothing nests, nothing commits independently, and one Unit of Work still maps to one use case.
Negative:
- A handler that needs an external result to compute its write still holds a transaction across that call. Protean answers with guidance and, once built, a diagnostic. An operator who ignores both will hold locks across the network.
- The "no repository access, no transaction" guarantee constrains the implementation. Opening a session eagerly is no longer free, because handler methods that only call out would start holding connections.
- Splitting persistence from external calls means more handler methods, and the value one needs from the other travels on an event.
Alternatives Considered
-
@handle(Event, unit_of_work=False), the original request. Rejected on granularity and on safety. It is Spring'sNOT_SUPPORTEDapplied to a whole handler rather than a block, so it switches off OCC retry and the idempotency policy for everything else the handler does. Worse, delivery is at-least-once: when a handler commits three sub-units and fails on the fourth, the engine redelivers the message. The handler re-applies the first three, and a visible full rollback becomes a silent duplicate. Django'snon_atomic_requestsis the closest precedent, and it is safe because HTTP requests are not redelivered. Messages are. -
A
prepare=phase on@handle, running before the transaction and not replayed on retry. Rejected because the boundary is too thin to explain and it fails a common case: a handler that must read aggregate state to decide whether to make the call cannot do the deciding in a phase that may not read. -
A journaled side effect in the style of Temporal's
SideEffect, recording a result against(message_id, key)so retries and redeliveries read it back. Rejected as complexity in the wrong place. The journal would exist only to make the persist-and-call-out shape safe to retry and redeliver. The remote system's idempotency key already solves that problem, and only the remote system can guarantee it. -
A suspend scope, a context manager running a block with no transaction. Rejected as unnecessary. The "no repository access, no transaction" guarantee gives the same result by splitting the method, which is the shape we want people to write anyway. ADR-0027 does not rule this out: its rejection of independent inner transactions rests on savepoint reasoning, and removing a transaction needs no savepoints.
-
Deterministic ordering for sibling handler methods. Rejected. Making the order stable would legitimize depending on it, which is the coupling this ADR rules out. Sibling methods are independent, and the fix is that a failure in one no longer skips the others.