ADR-007: Atomic writes and compensation for cross-aggregate transactions
Date: 2026-08-17 Status: Accepted
Context
checkoutBooking and returnBooking (packages/application/scheduling) each perform a real,
persisted write against the Aircraft aggregate — aircraftDispatcher.dispatch()/.release() —
before persisting the corresponding Flight/Booking writes, with no rollback or compensation if
the latter fails:
checkoutBooking.ts:aircraftDispatcher.dispatch()persists beforeflightRepo.save()/bookingRepo.update().returnBooking.ts:aircraftDispatcher.release()persists beforeflightRepo.update()/bookingRepo.update().
A transient failure between the two leaves the Aircraft in a state (in_use/available) that
doesn’t match reality, and — critically — makes the affected Booking unrecoverable through the
normal use case: a retry re-runs the Aircraft-side precondition check against the
already-mutated Aircraft and fails with an unrelated-looking error
(aircraft_already_in_use/aircraft_not_in_use) that masks what actually happened. Flagged
independently in three prior code reviews (issues #18/#21, #21, #30) before being tracked as
issue #31.
Cloudflare D1’s real transaction/batch capabilities were verified empirically (via the D1 test harness from issue #33) rather than assumed:
db.batch([...statements])(available on the shared drizzledbclient every repository inpackages/infrastructure/repositoriesis constructed from) is genuinely atomic for real SQL failures — confirmed by batching a valid insert with a unique-constraint-violating insert and observing zero rows persisted afterward.- Batch has no cross-statement conditional logic — every statement executes independently.
A conditional
UPDATE ... WHERE usage_status = ?matching zero rows is not a batch error; the batch still “succeeds,” and any other statements in it still commit. This rules out combining issue #40’s conditional Aircraft update (AircraftRepositoryD1.updateIfUsageStatus) with unconditional Flight/Booking writes in one batch — doing so would silently reintroduce a variant of #40’s concurrency race.
Decision
A hybrid of atomic-write-where-possible and compensation-for-the-rest:
aircraftDispatcher.dispatch()/.release()is left exactly as-is — already a single, already-guarded (issue #40), already-committed write, not touched by this decision.- Flight + Booking writes are combined into a single atomic D1 batch via a new
Scheduling-owned port,
FlightBookingWriter(saveFlightAndUpdateBookingfor checkout,updateFlightAndBookingfor return), implemented asFlightBookingWriterD1usingdb.batch(). This is a genuine single atomic write for that pair, per the empirical finding above. - If the write fails, it’s first verified, not assumed lost:
flightRepo.findById(...)is re-checked before compensating, because a write can report failure while having genuinely committed (e.g. the confirmation was lost after D1 processed the batch). Compensating a write that actually landed would create a worse inconsistency than not compensating at all — the Aircraft reverted while a real Flight/Booking already reflects the opposite state, letting a second booking dispatch the same airframe. If the write did land, the use case returns success. A thrown error from the write call (rather than a returnedResult) is treated the same as a returned error, so it can’t skip this path and propagate uncaught. - If the write genuinely didn’t land, the Aircraft write is automatically compensated via
AircraftDispatcher.compensateUsageStatus(aircraftId, revertTo, expectedCurrentStatus)— a narrow port method added specifically for this (see below), not a plaindispatch()/release()call. - If the compensating call also fails (a second, independent failure), the use case returns
a distinctly-labeled
partial_failure_needs_recoveryerror instead of letting a later retry hit a confusing, unrelated-lookingaircraft_already_in_use/aircraft_not_in_use.
Compensation needed its own port method, not a plain dispatch()/release() call. The
first implementation of this decision reused the existing dispatch()/release() methods for
compensation (calling them with no optional params, on the theory that this would leave meter
readings/grounding untouched). That was wrong in one real case, caught in PR review before
merge: release() grounds the aircraft when a defect is reported, and dispatch()’s own domain
guard (canBeBooked) rejects a grounded aircraft — so compensating a defect-reported return’s
write failure via dispatch() would always fail, degrading every such failure straight to
partial_failure_needs_recovery regardless of whether recovery was actually needed. Fixed by
adding AircraftDispatcher.compensateUsageStatus(): it reuses issue #40’s conditional-update
mechanism directly (so a genuine concurrent change is still guarded against via
expectedCurrentStatus), but deliberately bypasses dispatchAircraft()/releaseAircraft()’s
own domain guards entirely — a compensation isn’t a new dispatch/release request subject to
those business rules, it’s putting the flag back the way it was.
What compensation deliberately does not revert: meter readings (currentHobbs/
currentTach) and grounding (from a reported defect) are untouched by
compensateUsageStatus — it only ever changes usageStatus. This is intentional — a recorded
meter reading or a reported defect is a real-world fact independent of whether the
Flight/Booking bookkeeping transaction completes. Reverting them would be actively wrong
(un-grounding an aircraft with a genuine reported defect, or erasing a meter value the pilot
actually read).
UsageEventRecorder (returnBooking’s post-write Billing event) is explicitly out of
scope — a different bounded context (Billing) with its own domain validation
(recordUsageEvent); folding it into this Scheduling-owned batch would require the port to know
about Billing shapes. Left as a distinct, smaller follow-up if it needs the same treatment.
AircraftDispatcherD1’s own concurrency-guard mechanism (issue #40) is untouched by this
decision — different failure mode (concurrent access vs. partial-failure ordering), tracked and
fixed separately.
Consequences
checkoutBooking/returnBookinggain aflightBookingWriter: FlightBookingWriterparameter.CheckoutBookingError/ReturnBookingErrorgain two new variants:flight_booking_write_failed(the batch failed, compensation succeeded — safe to retry) andpartial_failure_needs_recovery(compensation also failed — the Aircraft is left in a state requiring manual/automated recovery; not currently backed by an automated reconciliation process).- A retry after a
flight_booking_write_failedresult succeeds cleanly, since compensation already restored the Aircraft’s priorusageStatusbefore that error was returned. - No schema change.
AircraftDispatchergains one new port method,compensateUsageStatus(aircraftId, revertTo, expectedCurrentStatus), used exclusively for this compensation —dispatch()/release()’s own signatures and guards are unchanged.
Alternatives considered
- Single D1 batch spanning Aircraft + Flight + Booking. Rejected — the Aircraft write’s conditional nature (issue #40) is incompatible with unconditional batch-mates; see Context.
- Compensating action for the full three-way gap, without narrowing anything via batch. Works, but leaves the widest failure window (any of three independent writes can fail). Rejected in favor of shrinking the window with a real atomic batch where D1 allows it.
- Reconciliation sweep as the primary mechanism. Not needed — the hybrid above closes the gap for the overwhelmingly common single-failure case; building a general reconciliation process for the rare residual double-failure case is exactly the kind of general saga/outbox framework this decision’s scope (issues #18/#21/#30’s two confirmed call sites only) deliberately excludes. The distinctly-labeled error is the honest, minimal substitute.