An application saves an order and then publishes a message so that invoicing hears about it. Another, during a migration, writes every change to the old system and to the new one, so that both stay current until cutover. In the code, each looks like one operation: two calls in one method, often inside one try block.
It is two operations. Each system commits or fails on its own, and nothing commits the pair. If the process stops between the two writes, or the second write succeeds and its answer never arrives, the systems disagree, and neither of them records that they should not. Whether the disagreement can be found and repaired is decided when the write is designed, not by the error handler after it fails.
This analysis is for engineers who build or review integrations that write one business change to more than one system: a database and a message broker, two databases, a SharePoint list and a line-of-business application, or the old and the new system during a migration. Microsoft's documentation was checked on 6 November 2026. No tenant, subscription or customer system was accessed; the one experiment below runs locally against two SQLite databases.
Why two writes drift apart
Microsoft's architecture guidance describes the failure in the same terms wherever it meets it. The Transactional Outbox guide starts from a service that commits an order and then publishes an event: "This approach works until an error occurs between saving the order object and publishing the event", and "Lost events can cause data inconsistencies across the application." The Web-Queue-Worker style names the same window: "The web front end can fail after it writes to the database but before it sends a message to the queue." The CQRS pattern gives the reason: "you usually can't enlist message brokers and databases into a single distributed transaction".
Azure Service Bus names both ways a pair of writes goes wrong when it promises that transfers between its own entities avoid them: "loss (source succeeds and target fails)" and "duplication (source fails and target succeeds)" (Service Bus transactions). An application that writes to two systems has neither promise.
What each failure leaves behind, in our reading of those pages:
| Where the operation stops | System A | System B | What the application knows afterwards |
|---|---|---|---|
| After A commits, before B is called | The change | Nothing | Nothing: a process that has stopped runs no error handler |
| B refuses the write | The change | Nothing | That B refused, so A can be undone |
| B applies the write; the answer is lost | The change | The change | Not whether B applied it |
| The application retries after the lost answer | The change | The change twice, unless B recognises the repeat | Not that it is a repeat |
The last two rows are the ones an error handler cannot see. The Idempotent Consumer pattern lists the fourth among the sources of duplicates: a send that succeeded, but whose confirmation was lost, is sent again, and the broker holds two copies. A missing log entry on B's side does not settle the third, as our analysis of missing traces explains. Reversing the order of the writes moves the window to the other system without closing it, and the experiment below shows what an undo in the error handler does to the second and third rows.
How far each transaction reaches
Two writes are one transaction only when one coordinator can commit or roll back both. Microsoft documents several coordinators, and each page says where its reach ends.
| Where the writes go | What commits together | Where it ends |
|---|---|---|
| Databases in Azure SQL Database, or across instances of SQL Managed Instance | Elastic database transactions, which "use two-phase commit" | "Only transactions across databases in SQL Database are supported"; not between SQL Database and SQL Managed Instance |
| SQL Managed Instance with SQL Server, other databases and applications | The Distributed Transaction Coordinator (DTC) for SQL Managed Instance | Participants that can join DTC; "Distributed transactions to Azure SQL Database aren't supported with DTC" |
| Azure Service Bus | Operations on one queue, topic or subscription, and transfers through a second entity | The databases and services a message handler calls, which don't "automatically enlist"; the Basic tier has no transactions |
| Azure Cosmos DB | A transactional batch | One logical partition of one container |
| Dynamics 365 finance and operations apps and Dataverse | Dual-write, which "rolls back the records in the transaction" in both environments when a transaction misses its time limit | Two minutes per transaction, and 1,000 records per transaction towards Dataverse |
Sources for the table, checked on 6 November 2026: elastic transactions, DTC for SQL Managed Instance, Service Bus transactions, the Transactional Outbox guide for Cosmos DB, and the dual-write overview and dual-write limits.
Our reading: every row has the same shape. A coordinator commits both sides only when every participant was built to take part in it, which is why dual-write can roll back two environments designed for it. A database and a broker, or a database and an HTTP API, have no such arrangement, and one TransactionScope around both calls does not create one: Service Bus says the databases and services its message handlers call don't automatically enlist. For a SharePoint or Microsoft Graph write paired with a write elsewhere, the pages we read describe no transaction the other system can join. Microsoft's data guidance starts from the other end: "Define the required consistency level for each component, and prefer eventual consistency where possible" (Data considerations for microservices).
What makes the result recoverable
Without a coordinator, the two systems will sometimes disagree for a while. During that window, in the words of the event-driven architecture style, "different parts of the system have a different view of the current state". Where no window is acceptable, the Compensating Transaction pattern is plain: "Use strong consistency mechanisms or atomic transactions across all steps instead." For us, that means a coordinator from the table, or one system, rather than a cleverer pair of writes.
Where a window is acceptable, the design is recoverable if, after any failure, a process can bring both systems back into agreement without guessing what was intended. Microsoft's data guidance names what that takes: "an extra piece of data that captures the state of a unit of work that spans multiple services". Our reading is that each mechanism below is a form of it: an outbox row, an inbox key, a saga's progress record, a change token.
One write is the record, and the other is derived from it
The Transactional Outbox pattern turns the second write into a delivery from the first. "When you save the business object and its events within the same database transaction, the system guarantees no data loss", and "a separate service or worker process queries the outbox table for unhandled entries, publishes them, and marks them as processed" (Transactional Outbox). Microsoft documents it on Azure Cosmos DB, where the document and its event share a transactional batch and the change feed tracks what has been processed, and on Dataverse, where a synchronous plug-in writes the outbox record "as part of the same transaction" and an asynchronous plug-in sends it to Service Bus.
The record need not be a table the application adds. When the first system keeps its own log of changes, the copy can be derived from that log; the Strangler Fig pattern mentions change data capture, "reading the transaction log instead". SharePoint already works this way. A webhook notification "doesn't include any information about the changes that triggered it": the receiver reads them with GetChanges and keeps the change token, and after an outage "the call to the GetChanges() recovers the changes that were missed" (SharePoint webhooks). Microsoft Graph offers delta query for the resources it supports.
Our reading for Microsoft 365 integrations: the code that changes a SharePoint list should not also write to the other system. The notification is a prompt to read, the change log is the record, and the copy is derived from it.
Delivered at least once, applied once
Deriving the second write trades loss for duplicates. The Dataverse outbox "guarantees at-least-once delivery", and Microsoft's instruction is direct: "Design consumers for idempotent processing, as this pattern provides at-least-once delivery and duplicate messages might occur." In the Cosmos DB implementation, the change feed processor restarts from the last batch it completed, so "the application might have already sent some messages to Service Bus", and its sample turns on Service Bus duplicate detection. The Idempotent Consumer pattern explains why that is not enough: "Don't substitute broker deduplication for idempotent consumer logic", because detection works on the send side, within a time window. The consumer records a stable key for each message by "writing the deduplication marker and the business side effects in the same transaction", which the pattern calls the Inbox. The key names the operation, not the delivery attempt: the same contract our hybrid integration analysis sets out for a single call.
What cannot be derived is compensated, not rolled back
Some second writes are not notifications. A reservation, a payment or an approval recorded in another system has to succeed for the business operation to stand. The Saga pattern splits such an operation into local transactions, each committed in its own system, and undoes completed steps with compensating transactions when a later one fails. That is not a rollback: "You might think that you can simply restore the system to its original state, but this approach can overwrite changes from other concurrent application instances" (Compensating Transaction). A compensation "doesn't necessarily return the system data to its state at the start of the original operation", "Compensating transactions don't always work", and each step should be idempotent, because a retried step can run more than once. While a saga runs, "there's no built-in isolation across services".
Two design rules follow. Put the irreversible last: the pivot transaction is "the point of no return", operations "such as external side effects or legally binding actions" cannot safely or meaningfully be undone, and the guidance is to "Design the workflow so that irreversible steps occur only after all critical validations succeed." And keep the record: "The system should record progress so that it can resume the compensating transaction from the point of failure"; where that is not enough, "Sometimes manual intervention is the only way to recover from a failed step."
Disagreement is measured, not assumed away
None of these mechanisms proves that the two systems agree. Microsoft's own migration example closes a period of coexistence with "Validate consistency between both databases before cutover" (Strangler Fig). Graph tells applications to "be prepared for replays", and a 410 Gone from a delta request means starting again with a full synchronisation (delta query). Only a comparison between the record and the copy establishes what is true. We recommend a scheduled reconciliation, by key and by content, with a named owner for the differences.
A local counterexample: the undo in the error handler
The tempting repair for the first table is an undo: if the second write fails, delete the first. We tested it, with an outbox and two kinds of consumer, against two local SQLite databases standing in for systems A and B. The Python below is original, offline and synthetic, and it models no Microsoft service. A crash is an exception the application does not catch, so its error handler never runs; calling the relay again stands in for the restart.
import sqlite3
class Crash(BaseException):
"""The process stops here, so no `except Exception` block runs."""
def store(*tables):
db = sqlite3.connect(":memory:")
for table in tables:
db.execute(f"CREATE TABLE {table}")
return db
def report(case, a, b):
count_a, count_b = (db.execute("SELECT COUNT(*) FROM orders").fetchone()[0]
for db in (a, b))
state = "agree" if count_a == count_b else "DIFFER"
print(f"{case}: A={count_a} B={count_b} {state}")
def two_writes(a, b, failure):
with a:
a.execute("INSERT INTO orders VALUES ('order-1')")
try:
if failure == "crash between the writes":
raise Crash
if failure == "B refuses":
raise ValueError("refused")
with b:
b.execute("INSERT INTO orders VALUES ('order-1')")
if failure == "B applies, answer lost":
raise TimeoutError
except Exception:
with a: # the undo in the error handler
a.execute("DELETE FROM orders WHERE id = 'order-1'")
def write_with_outbox(a):
with a: # the order and its message commit together, or neither does
a.execute("INSERT INTO orders VALUES ('order-1')")
a.execute("INSERT INTO outbox VALUES ('message-1', 0)")
def relay(a, b, consume, crash_after_delivery=False):
unsent = a.execute("SELECT id FROM outbox WHERE sent = 0").fetchall()
for (message,) in unsent:
consume(b, message)
if crash_after_delivery:
raise Crash
with a:
a.execute("UPDATE outbox SET sent = 1 WHERE id = ?", (message,))
def plain(b, message):
with b:
b.execute("INSERT INTO orders VALUES ('order-1')")
def inbox(b, message):
with b: # the message key and its effect commit together
try:
b.execute("INSERT INTO inbox VALUES (?)", (message,))
except sqlite3.IntegrityError:
return # seen before: nothing to apply
b.execute("INSERT INTO orders VALUES ('order-1')")
for failure in ("no failure", "crash between the writes",
"B refuses", "B applies, answer lost"):
a, b = store("orders(id)"), store("orders(id)")
try:
two_writes(a, b, failure)
except Crash:
pass
report(f"two writes, {failure}", a, b)
for crash, consume in (("before delivery", inbox),
("after delivery", plain),
("after delivery", inbox)):
a = store("orders(id)", "outbox(id PRIMARY KEY, sent)")
b = store("orders(id)", "inbox(id PRIMARY KEY)")
write_with_outbox(a) # a crash before delivery stops the process here
if crash == "after delivery":
try:
relay(a, b, consume, crash_after_delivery=True)
except Crash:
pass
relay(a, b, consume) # after the restart: send whatever is still unsent
report(f"outbox, crash {crash}, {consume.__name__} consumer", a, b)Its observed local output, with Python 3.15.0 and the standard library only:
two writes, no failure: A=1 B=1 agree
two writes, crash between the writes: A=1 B=0 DIFFER
two writes, B refuses: A=0 B=0 agree
two writes, B applies, answer lost: A=0 B=1 DIFFER
outbox, crash before delivery, inbox consumer: A=1 B=1 agree
outbox, crash after delivery, plain consumer: A=1 B=2 DIFFER
outbox, crash after delivery, inbox consumer: A=1 B=1 agreeThe undo repaired the refusal and caused the fourth disagreement: without it, the lost answer leaves both systems holding the order; with it, A removed an order that B kept. The crash between the writes left B without the order and no record anywhere that it is owed one. The outbox survived both crashes, because the unsent message was committed with the order, but a crash between delivery and marking the message sent delivered it twice, and only the inbox consumer applied it once. Without the record, a crash loses the second write; without the idempotent consumer, the record delivers it twice.
The model has one process, no network, no concurrency and no ordering, and it counts rows because each case writes one order. It establishes the logic of these failure points and nothing about Service Bus, Cosmos DB, Dataverse or SharePoint. In a real system a matching count is not evidence of agreement: our cutover analysis has a counterexample in which two inventories hold the same number of entries and still disagree.
During a migration, the second system is the replacement
Writing every change to both the old and the new system is the same pattern with a deadline attached: at cutover, somebody must decide which copy is right. In Microsoft's Strangler Fig example for a database, the new database is loaded once, kept current from the old one by change data capture while both systems run, and checked before it becomes the system of record. Rollback remains possible while the old tables and the synchronisation exist; once they are removed, going back means you "must restore those objects and replay data changes".
For SharePoint, our analysis of the cutover write boundary sets out what changes once the new farm accepts its first write, and Field Guide chapter 9 gives a rollback procedure for each state under one rule: one writer at a time. This article is the general form of that rule. Wherever two systems hold the same data, decide which one is written, and derive the other.
What we recommend, in order
These are our recommendations, not Microsoft's.
- List every operation that writes to more than one system, including those in flows, webhook receivers and scheduled jobs, and name the record and the copy for each. Microsoft's guidance: "Use a single source of truth when you require strong consistency."
- Check whether one coordinator covers the pair, in the documentation of both systems. If none does, stop calling the operation a transaction, and name the window instead.
- Derive the second write from an outbox committed with the record, or from the record's own change log.
- Make every consumer idempotent, with a key that names the operation, committed with the effect.
- For a second write that must succeed, design the saga: a compensation for each step before the pivot, irreversible steps after the validations, and a named person for what compensation cannot settle.
- Reconcile on a schedule, by key and by content rather than by count, with an owner for the differences.
- Test the failure points, not the happy path: a crash after the first write, a refusal of the second, a lost answer after the second, and a crash between delivery and acknowledgement.
What this does not settle
- Whether a particular pair of systems can share one transaction. Only the documentation of both participants answers that. For SharePoint and Microsoft Graph, the pages we read describe no transaction another system can join.
- How long SharePoint keeps its change log. The current pages we read do not say, and an integration that relies on
GetChangesafter a long outage needs that number. - What Dynamics 365 dual-write guarantees outside live synchronisation. Its overview lists pause and catch-up modes for offline and asynchronous operation; we did not establish what they guarantee.
Where this fits
- A SharePoint cutover stops being reversible when the new farm accepts writes is this article's migration case, with a decision matrix for each state after cutover.
- Field Guide chapter 9 turns "one writer at a time" into a rollback procedure.
- A hybrid SharePoint integration needs more than a successful sign-in covers the retry contract of a single downstream call.
- The green test that measured nothing has a send whose idempotency marker lived in a table that did not exist, and the at-least-once or at-most-once choice behind it.
Sources, checked on 6 November 2026
- Azure architecture guidance: Transactional Outbox pattern with Azure Cosmos DB, Idempotent Consumer pattern, Compensating Transaction pattern, Saga pattern, CQRS pattern, Strangler Fig pattern, event-driven architecture style, Web-Queue-Worker architecture style, data considerations for microservices
- Power Platform: Transactional Outbox pattern with Dataverse
- Azure Service Bus: transaction processing
- Azure SQL: distributed transactions across cloud databases, DTC for SQL Managed Instance
- Dynamics 365: dual-write overview, dual-write limits
- SharePoint: webhooks overview
- Microsoft Graph: delta query
Support independent engineering knowledge
Knowledge, Analysis, the Field Guide and the tools are free to read and use, and they stay that way. If this article saved you time, you can support the research and writing behind it. Support is voluntary and unlocks nothing.
Buy me a coffee (opens in a new tab)João Lívio's page on buymeacoffee.com