transaction boundaries
flowchart TDA[one user action] --> B{several writes, one fact?}B -->|yes| C[one transaction in owning service]B -->|external call involved| D[record durable intent]D --> E[external call]E --> F[record outcome fact]C -.typed diagnostic.-> G[failure surface]D -.typed diagnostic.-> G
rule
when one user action must produce several writes atomically — a row and its dependent fact, a record and its projection update — those writes live in one database transaction, owned by the domain service that owns the invariants. callers cannot opt out.
boundary placement
| write shape | decision |
|---|---|
| several table writes that encode one fact | one transaction in the owning service. |
| a database write plus a provider call | never one transaction. record intent durably first, per durable handoffs, then the provider call, then the outcome fact. |
| a database write plus a workflow start | same: record the durable fact, then hand off. |
| several unrelated facts from one request | separate transactions, each owned by its fact's service. |
the durable-handoff rule outranks atomicity: never hold a transaction open across an external call, never make an external system's success depend on your commit timing.
failure shape
a transaction failure is a typed diagnostic at the owning service's boundary — retryable classification when the engine says so, per diagnostic errors. callers never see a half-applied write or a raw engine error.
do not
- wrap a provider call inside a database transaction.
- let a route or entrypoint own transaction begin/commit; the service that owns the facts owns the boundary.
- accumulate "one more write" into a transaction spanning two domains' invariants.
- swallow a commit failure and return success because the first write "probably landed".
verification
- force the second write of a multi-write path to fail (constraint or injected fault, non-production): confirm zero writes landed, the diagnostic is the typed retryable one, no durable record of the partial action exists.
- search for transaction usage spanning provider calls — expect none.
why
- writes encoding one fact must land or vanish together, or the database permanently proves a partial action that never happened.
- boundary in the owning service (not each caller) makes the invariant non-optional and the failure one typed diagnostic instead of a half-applied mystery.
- durable-handoff outranking atomicity keeps external calls outside the transaction — a slow provider can never hold locks or leave two systems disagreeing.
good and bad
bad
// provider call INSIDE the transactionasync function startWorkflow(workflowInput: WorkflowInput) {try {await database.transaction(async (tx) => {const run = await tx.insert(workflowRun).values({id: createWorkflowRunId(),status: "starting",});// external call while the database lock is heldconst providerResult = await provider.startWorkflow({ runId: run.id });});} catch (error) {// state unknown: row committed? provider call in flight?}}
three failures:
- lock held during the external call — a briefly slow provider holds a connection for 10+ seconds, starving other writers.
- ambiguous failure — provider succeeds but the network breaks before the response: database rolls back, provider already started (duplicate starts on retry).
- partial rollback — response parsing fails: row rolled back, provider already started, systems out of sync.
good
// phase 1: commit durable intent (fast, inside transaction)async function createWorkflowStartIntent(workflowInput: WorkflowInput) {return await database.transaction(async (tx) => {const run = await tx.insert(workflowRun).values({id: createWorkflowRunId(),status: "start_intent_recorded", // explicit stateinput: workflowInput,});const intent = await tx.insert(workflowStartIntent).values({id: createWorkflowStartIntentId(),run_id: run.id,status: "pending", // waiting for worker to dispatchcreated_at: new Date().toISOString(),});return { runId: run.id, intentId: intent.id }; // commit, release lock});}// phase 2: worker dispatches the intent (fails/retries independently)async function dispatchWorkflowStart(intentId: string) {const intent = await database.select().from(workflowStartIntent).where(eq(workflowStartIntent.id, intentId));if (!intent) throw new Error("intent not found");if (intent.status === "dispatched") return; // idempotent — retry succeedstry {// provider call, outside any transactionconst providerResult = await provider.startWorkflow({ runId: intent.run_id });await database.update(workflowStartIntent).set({status: "dispatched",provider_run_id: providerResult.id,dispatched_at: new Date().toISOString(),}).where(eq(workflowStartIntent.id, intentId));} catch (error) {await database.update(workflowStartIntent).set({status: "failed",error_message: error.message,retried_at: new Date().toISOString(),}).where(eq(workflowStartIntent.id, intentId));throw error; // infrastructure can retry the job}}// on startup, reconcile unfinished intentsasync function reconcileStartIntents() {const pending = await database.select().from(workflowStartIntent).where(eq(workflowStartIntent.status, "pending"));for (const intent of pending) {await dispatchWorkflowStart(intent.id); // safe: dispatch is idempotent}}
why the separation makes failures safe:
- lock held only for the write (milliseconds), not the provider call.
- idempotent — provider succeeds but the app crashes: startup reconciliation retries the intent.
- each system owns its state — database owns intent and run facts, provider owns the run, a separate worker orchestrates the handoff.