transaction boundaries

flowchart TD
A[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 shapedecision
several table writes that encode one factone transaction in the owning service.
a database write plus a provider callnever one transaction. record intent durably first, per durable handoffs, then the provider call, then the outcome fact.
a database write plus a workflow startsame: record the durable fact, then hand off.
several unrelated facts from one requestseparate 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 transaction
async 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 held
const 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 state
input: workflowInput,
});
const intent = await tx.insert(workflowStartIntent).values({
id: createWorkflowStartIntentId(),
run_id: run.id,
status: "pending", // waiting for worker to dispatch
created_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 succeeds
try {
// provider call, outside any transaction
const 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 intents
async 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.

search pages

go to any page