Dataverse Transaction & Rollback Laboratory: Atomicity, Plug-in Pipelines, and Failure Engineering with C#

Understanding where Dataverse transactions begin and end, how rollback propagates through synchronous operations, and why database atomicity is not the same as distributed consistency.

Power Platform Engineering Academy — Five Years in Five Days

Application: APP-008 — Transaction & Rollback Lab
Technologies: Dataverse SDK, C#, ExecuteTransactionRequest, ExecuteMultipleRequest, plug-ins, Custom APIs, Model-Driven Apps
Status: Proposed engineering design — execution and results pending


Introduction

A Dataverse operation may involve substantially more than inserting or updating a single row.

A request can trigger synchronous plug-ins, perform additional Dataverse operations, evaluate business rules, and fail after several changes have been attempted.

Whether those changes survive depends on the transaction boundary.

This becomes especially important when implementing multi-record business operations, integration workflows, and server-side extensions.

Consider an operation that creates a parent record and three child records. If the third child fails validation, should the parent and first two children remain?

The answer depends on how the operation was executed.

Three mechanisms deserve careful investigation:

  1. Independent Organization Service requests.
  2. Batched requests using ExecuteMultipleRequest.
  3. Atomic requests using ExecuteTransactionRequest.

Synchronous plug-in execution introduces another dimension: operations performed inside an existing Dataverse transaction may participate in that transaction and be rolled back when it fails.

However, Dataverse database transactions do not automatically include HTTP calls, external databases, email delivery, Azure Service Bus consumers, or other distributed systems.

APP-008 — Transaction & Rollback Lab is designed to make these boundaries observable.

Our central engineering question is:

Which Dataverse changes are committed, which are rolled back, and what happens when an operation fails inside or outside the platform’s transactional boundary?

1. ACID Transactions in Dataverse

A database transaction is commonly described using ACID properties.

PropertyMeaningEngineering relevance
AtomicityAll transactionally included changes commit or roll back togetherPrevents partial database state
ConsistencyTransactional changes must satisfy enforced constraintsPreserves defined invariants
IsolationConcurrent transactions interact according to database isolation behaviorInfluences locking and concurrency
DurabilityCommitted changes survive subsequent failuresEstablishes persisted state

These concepts help reason about Dataverse, but they do not mean developers control its database transactions as they would in a directly managed SQL Server application.

Dataverse exposes supported service contracts and execution pipeline mechanisms.

Developers should not assume they can select SQL isolation levels, issue arbitrary transaction-control statements, or obtain unrestricted access to underlying database locks.

The correct approach is to investigate documented platform transaction semantics and validate application-level invariants through experiments.

2. Three Execution Models

<text>Model A — Independent service calls</text>

Create Parent → Commit
Create Child A → Commit
Create Child B → Failure
Create Child C → Not executed

If the first two operations completed successfully as independent requests, a later failure does not automatically undo them.

<text>Model B — ExecuteMultipleRequest</text>

ExecuteMultiple
├── Create Parent → Independent request
├── Create Child A → Independent request
└── Create Child B → Failure

ExecuteMultipleRequest batches operations to reduce round trips. It does not establish one atomic transaction for the entire batch.

Partial success is possible.

<text>Model C — ExecuteTransactionRequest</text>

ExecuteTransaction
├── Create Parent
├── Create Child A
└── Create Child B → Failure
│
▼
Roll back transaction

For supported operations within the transaction, a failure causes the transaction to roll back.

This is the key behavior APP-008 will verify.

3. Application Architecture

APP-008 Transaction & Rollback Laboratory
│
├── C# Transaction Runner
│ ├── Independent Requests
│ ├── ExecuteMultiple
│ ├── ExecuteTransaction
│ ├── Failure Injection
│ └── State Verification
│
├── Dataverse
│ ├── pea_transactionparent
│ ├── pea_transactionchild
│ └── pea_transactionrun
│
├── Optional Plug-in Extension
│ ├── PreOperation
│ ├── PostOperation
│ └── Transaction Context Diagnostics
│
└── Model-Driven App
├── Entry Dashboard
├── Transaction Parents
├── Transaction Children
├── Experiment Runs
└── Rollback Diagnostics

The Model-Driven App is the operational interface.

It allows engineers to inspect the actual database state after each experiment.

A console message saying “rollback succeeded” is not sufficient evidence.

4. Dataverse Data Model

4.1 Transaction Parent

Logical name: pea_transactionparent

ColumnTypePurpose
pea_namePrimary nameParent identifier
pea_batchidSingle line of textExperiment correlation
pea_descriptionMultiple lines of textScenario description
pea_amountDecimal numberExample business value

4.2 Transaction Child

Logical name: pea_transactionchild

ColumnTypePurpose
pea_namePrimary nameChild identifier
pea_batchidSingle line of textExperiment correlation
pea_parentLookupRelated parent
pea_sequenceWhole numberOperation order
pea_amountDecimal numberExample child value

Create a one-to-many relationship from pea_transactionparent to pea_transactionchild.

4.3 Transaction Run

Logical name: pea_transactionrun

ColumnTypePurpose
pea_namePrimary nameRun identifier
pea_batchidSingle line of textCorrelation key
pea_executionmodeSingle line of textIndependent, Multiple, Transaction
pea_failureinjectedYes/NoWhether failure was intentional
pea_expectedrollbackYes/NoExpected atomicity
pea_parentcountWhole numberObserved surviving parents
pea_childcountWhole numberObserved surviving children
pea_successYes/NoExpected outcome matched
pea_messageMultiple lines of textDiagnostics
pea_executedonDate and timeExecution timestamp

Important design decision: pea_transactionrun is written after the tested operation completes or fails, using a separate service request.

If the diagnostic record were created inside the same transaction that later rolls back, the diagnostic record would also disappear.

That would make the failed experiment harder to observe.

5. Failure Injection Strategy

The experiment requires a deterministic failure.

Relying on random timeouts or temporary network failures would make results difficult to reproduce.

The initial approach will deliberately attempt to create a child record using an invalid attribute name.

For example:

var invalidChild = new Entity("pea_transactionchild")
{
["pea_name"] = "Invalid Child",
["pea_nonexistentcolumn"] = "Force failure"
};

The invalid column should cause Dataverse to reject the request.

The test harness must verify that the failure is related to the intended invalid attribute, rather than merely catching any exception.

A later experiment can use a synchronous plug-in that deliberately throws InvalidPluginExecutionException.

That provides a second way to examine rollback through the server execution pipeline.

6. Experiment A: Independent Requests

The first experiment executes requests independently.

Guid parentId = service.Create(parent);
service.Create(childA);
service.Create(invalidChild);

The expected behavior is that successful earlier requests remain committed even when the later request fails.

The harness must query Dataverse after the failure to verify the surviving records.

This experiment establishes the baseline for comparison.

It also demonstrates why multiple sequential SDK calls do not automatically constitute a single business transaction.

7. Experiment B: ExecuteMultipleRequest

The second experiment places multiple Create requests inside an ExecuteMultipleRequest.

var request = new ExecuteMultipleRequest
{
Settings = new ExecuteMultipleSettings
{
ContinueOnError = true,
ReturnResponses = true
},
Requests = new OrganizationRequestCollection()
};

Each Create request is added to the collection.

The response contains information about individual requests and their faults.

The important observation is that earlier successful requests can remain committed even when a later request fails.

ExecuteMultipleRequest is a batching mechanism, not an atomic transaction mechanism.

The laboratory must inspect every response item rather than assume that one successful batch call means every contained request succeeded.

8. Experiment C: ExecuteTransactionRequest

The third experiment uses ExecuteTransactionRequest.

var request = new ExecuteTransactionRequest
{
Requests = new OrganizationRequestCollection(),
ReturnResponses = true
};

The request collection contains valid Create operations followed by an intentionally invalid operation.

When Dataverse rejects one operation, the transaction should roll back the transactionally included changes.

The experiment will verify that no parent or child records from the failed transaction remain.

The test must use a unique batch identifier to isolate the experiment from other records.

Reference: Execute messages in a single database transaction.

9. Transactional Dependencies and Record Identity

A subtle problem arises when creating related records in a single transaction.

Suppose the transaction must create a parent and then create children referencing that parent.

If the parent GUID is generated only after the Create request executes, the client cannot use that returned GUID while constructing later requests in the same request collection.

One solution is to assign a GUID to the parent entity before submission.

Guid parentId = Guid.NewGuid();
var parent = new Entity(
"pea_transactionparent",
parentId);

Children can then reference the same GUID.

var child = new Entity("pea_transactionchild")
{
["pea_name"] = "Child A",
["pea_parent"] = new EntityReference(
"pea_transactionparent",
parentId)
};

The transaction can submit the parent creation before the child creation.

This creates a reproducible dependency without requiring the client to inspect an intermediate response.

The implementation must still validate that the supplied GUID is accepted and that the relationship metadata is configured correctly.

10. Independent C# Laboratory

The project structure is:

PowerPlatformEngineeringAcademy/
└── Dataverse/
└── APP008.TransactionRollbackLab/
├── APP008.TransactionRollbackLab.csproj
└── Program.cs

Create the project:

dotnet new console --name APP008.TransactionRollbackLab
cd APP008.TransactionRollbackLab
dotnet add package Microsoft.PowerPlatform.Dataverse.Client

The program will execute three scenarios using distinct batch identifiers.

For each scenario, it will:

  1. Construct the same logical set of operations.
  2. Inject one deterministic failure.
  3. Capture the resulting fault.
  4. Query the surviving parent and child records.
  5. Compare observed counts with expected counts.
  6. Persist an experiment run record.
  7. Print the results for immediate inspection.

The following implementation assumes the Dataverse tables and relationship already exist.

11. Complete C# Transaction Runner

The implementation below compares independent requests, ExecuteMultipleRequest, and ExecuteTransactionRequest.

The experiment creates one parent and two valid children, followed by one intentionally invalid child.

The first three operations should succeed when executed independently. The fourth should fail.

For ExecuteMultipleRequest, successful operations can remain committed. For ExecuteTransactionRequest, the failure should roll back the entire request collection. These distinctions are documented by Microsoft. <Cite refs={[“turn913502search1″,”turn913502search2”]}/>

using System;
using System.Collections.Generic;
using System.Linq;
using System.ServiceModel;
using Microsoft.PowerPlatform.Dataverse.Client;
using Microsoft.Xrm.Sdk;
using Microsoft.Xrm.Sdk.Messages;
using Microsoft.Xrm.Sdk.Query;
const string ParentTable = "pea_transactionparent";
const string ChildTable = "pea_transactionchild";
const string RunTable = "pea_transactionrun";
string connectionString =
Environment.GetEnvironmentVariable("DATAVERSE_CONNECTION")
?? throw new InvalidOperationException(
"Missing DATAVERSE_CONNECTION.");
using var client = new ServiceClient(connectionString);
if (!client.IsReady)
throw new InvalidOperationException(client.LastError);
IOrganizationService service = client;
OrganizationRequestCollection BuildRequests(
string batchId)
{
Guid parentId = Guid.NewGuid();
var parent = new Entity(ParentTable, parentId)
{
["pea_name"] = $"Parent {batchId}",
["pea_batchid"] = batchId,
["pea_amount"] = 100m
};
Entity CreateChild(int sequence)
{
return new Entity(ChildTable)
{
["pea_name"] = $"Child {sequence}",
["pea_batchid"] = batchId,
["pea_sequence"] = sequence,
["pea_amount"] = sequence * 10m,
["pea_parent"] = new EntityReference(
ParentTable, parentId)
};
}
var invalidChild = CreateChild(3);
// Deliberately invalid attribute.
invalidChild["pea_nonexistentcolumn"] =
"Expected validation failure";
return new OrganizationRequestCollection
{
new CreateRequest { Target = parent },
new CreateRequest { Target = CreateChild(1) },
new CreateRequest { Target = CreateChild(2) },
new CreateRequest { Target = invalidChild }
};
}
int CountRecords(string table, string batchId)
{
var query = new QueryExpression(table)
{
ColumnSet = new ColumnSet(false)
};
query.Criteria.AddCondition(
"pea_batchid",
ConditionOperator.Equal,
batchId);
return service.RetrieveMultiple(query).Entities.Count;
}
void SaveResult(
string mode,
string batchId,
int parents,
int children,
bool passed,
string message)
{
var run = new Entity(RunTable)
{
["pea_name"] = $"{mode}-{batchId}",
["pea_batchid"] = batchId,
["pea_executionmode"] = mode,
["pea_failureinjected"] = true,
["pea_expectedrollback"] =
mode == "ExecuteTransaction",
["pea_parentcount"] = parents,
["pea_childcount"] = children,
["pea_success"] = passed,
["pea_message"] = message,
["pea_executedon"] = DateTime.UtcNow
};
service.Create(run);
}
void RunScenario(string mode)
{
string batchId = $"APP008-{Guid.NewGuid():N}";
var requests = BuildRequests(batchId);
bool expectedFailureObserved = false;
string diagnostic = "";
try
{
switch (mode)
{
case "Independent":
foreach (var request in requests)
{
service.Execute(request);
}
break;
case "ExecuteMultiple":
var multiple = new ExecuteMultipleRequest
{
Settings = new ExecuteMultipleSettings
{
ContinueOnError = false,
ReturnResponses = true
},
Requests = requests
};
var multipleResponse =
(ExecuteMultipleResponse)
service.Execute(multiple);
var faults = multipleResponse.Responses
.Where(r => r.Fault != null)
.ToList();
expectedFailureObserved =
faults.Count == 1 &&
faults[0].RequestIndex == 3 &&
faults[0].Fault.Message.Contains(
"pea_nonexistentcolumn",
StringComparison.OrdinalIgnoreCase);
diagnostic =
$"Batch faults: {faults.Count}";
break;
case "ExecuteTransaction":
var transaction = new ExecuteTransactionRequest
{
Requests = requests,
ReturnResponses = true
};
service.Execute(transaction);
break;
default:
throw new ArgumentOutOfRangeException(
nameof(mode));
}
}
catch (FaultException<OrganizationServiceFault> ex)
{
diagnostic = ex.Detail.Message;
expectedFailureObserved =
ex.Detail.Message.Contains(
"pea_nonexistentcolumn",
StringComparison.OrdinalIgnoreCase);
if (mode == "ExecuteTransaction" &&
ex.Detail is ExecuteTransactionFault fault)
{
expectedFailureObserved &=
fault.FaultedRequestIndex == 3;
}
}
int parents = CountRecords(ParentTable, batchId);
int children = CountRecords(ChildTable, batchId);
bool expectedState = mode switch
{
"Independent" =>
parents == 1 && children == 2,
"ExecuteMultiple" =>
parents == 1 && children == 2,
"ExecuteTransaction" =>
parents == 0 && children == 0,
_ => false
};
bool passed =
expectedFailureObserved && expectedState;
SaveResult(
mode,
batchId,
parents,
children,
passed,
diagnostic);
Console.WriteLine(
$"{mode}: Parents={parents}, " +
$"Children={children}, Passed={passed}");
if (!passed)
throw new InvalidOperationException(
$"Unexpected result for {mode}: {diagnostic}");
}
RunScenario("Independent");
RunScenario("ExecuteMultiple");
RunScenario("ExecuteTransaction");

11.1 Expected results

Execution modeParents remainingChildren remainingInterpretation
Independent12Earlier requests committed
ExecuteMultiple12Partial batch success
ExecuteTransaction00Atomic rollback

These are expected results, not observed results. The implementation must be compiled and executed against the configured environment before the laboratory can claim successful verification.

The code also uses message text to recognize the injected fault. A stronger harness should verify the actual fault structure and error code observed in the target environment, then establish a precise assertion.

12. Plug-in Transaction Pipeline

Dataverse plug-ins execute within the event processing pipeline.

The stages relevant to this experiment are:

StageTransaction behavior
PreValidationUsually outside the initial operation’s transaction; can participate in an existing transaction
PreOperationExecutes within the database transaction
MainOperationPlatform operation
PostOperation (synchronous)Executes within the database transaction
PostOperation (asynchronous)Runs separately after the main operation

A synchronous exception during PreOperation or PostOperation can cause transaction rollback.

PreValidation requires more careful reasoning. It is not universally outside a transaction, because nested operations can already have an active transaction context.

Microsoft documents these pipeline distinctions. <Cite refs={[“turn913502search18″,”turn913502search13”]}/>

12.1 Inspecting the transaction context

The SDK exposes:

context.IsInTransaction

This property indicates whether the current plug-in execution is within a database transaction.

A diagnostic plug-in can record the execution stage and transaction flag through tracing.

public sealed class TransactionProbePlugin : IPlugin
{
public void Execute(IServiceProvider serviceProvider)
{
var context =
(IPluginExecutionContext)
serviceProvider.GetService(
typeof(IPluginExecutionContext))!;
var tracing =
(ITracingService)
serviceProvider.GetService(
typeof(ITracingService))!;
tracing.Trace(
"Message={0}; Stage={1}; Depth={2}; " +
"IsInTransaction={3}; CorrelationId={4}",
context.MessageName,
context.Stage,
context.Depth,
context.IsInTransaction,
context.CorrelationId);
}
}

This plug-in can be registered on the Create message for the laboratory’s parent table at selected synchronous stages.

The resulting traces provide evidence about the actual execution context.

13. Failure Inside a Synchronous Plug-in

Consider a synchronous PostOperation plug-in that creates a child record and then throws an exception.

service.Create(child);
throw new InvalidPluginExecutionException(
"APP-008 intentional rollback test");

If this executes within the same transaction as the parent creation, both changes should be rolled back.

This is materially different from a console application making two independent requests.

The plug-in’s service operations participate in the pipeline transaction when executed through the supported service context.

A critical failure mode is swallowing an Organization Service exception and attempting to continue processing within the same transaction.

Microsoft documents transaction-related errors caused by plug-ins that catch service faults and continue after the transaction has already been invalidated. <Cite ref=”turn913502search11″/>

For that reason, a plug-in must not treat an exception from a transactional Dataverse operation as a harmless event that can always be ignored.

14. Why External Side Effects Cannot Be Rolled Back

Suppose a synchronous plug-in performs the following actions:

1. Create Dataverse record
2. Call external REST API
3. External API commits an operation
4. Plug-in throws exception
5. Dataverse transaction rolls back

The Dataverse record may disappear.

The external operation may remain committed.

The Dataverse transaction does not control the external system’s database.

This creates a distributed consistency problem.

The correct architectural response is not to assume that a larger Dataverse transaction will solve it.

Instead, engineers should evaluate patterns such as:

  • Transactional intent records.
  • Outbox-style processing.
  • Asynchronous integration.
  • Idempotent consumers.
  • Compensating actions.
  • Reconciliation processes.

An outbox-style pattern implemented with Dataverse still requires careful design of message delivery, retries, and duplicate handling. Creating an intent record transactionally does not by itself guarantee delivery to an external system.

15. Model-Driven App: PEA Transaction & Rollback Laboratory

The operational application will be named:

PEA Transaction & Rollback Laboratory

Entry dashboard

The dashboard should provide:

MetricPurpose
Total experiment runsNumber of completed experiments
Expected rollbacksNumber of atomic failure scenarios
Verified rollbacksRuns with no surviving transactional records
Partial commitsRuns with surviving records after failure
Failed assertionsUnexpected observed outcomes
Latest runMost recent experiment

Transaction Parents

A view displays surviving parent records and their batch identifiers.

Transaction Children

A view displays child records, their parent relationships, and execution sequences.

Experiment Runs

A view displays each execution mode, expected rollback, observed record counts, and pass/fail status.

Rollback Diagnostics

A form exposes the execution’s diagnostic message and allows engineers to inspect the resulting records.

The dashboard must provide meaningful operational information and navigation, not merely decorative charts.

16. Additional Failure Experiments

After validating the initial three execution modes, the laboratory should introduce more demanding scenarios.

Experiment D — Synchronous PreOperation exception

Register a plug-in that throws before the main database operation.

Verify that the requested record is not persisted.

Experiment E — Synchronous PostOperation exception

Create a related record inside the plug-in and then throw.

Verify that both the original and related changes are rolled back.

Experiment F — PreValidation inside nested execution

Invoke an operation from another synchronous plug-in and inspect IsInTransaction in its PreValidation stage.

Verify whether the nested execution participates in the existing transaction.

Experiment G — External HTTP side effect

Call a controlled external test endpoint and then force a Dataverse rollback.

Verify that the external side effect is not automatically reversed.

Experiment H — Concurrent transactions

Run controlled concurrent operations against overlapping records.

Measure duration, blocking symptoms, and faults without claiming access to Dataverse’s internal SQL lock state.

17. Performance and Transaction Duration

Transactions provide atomicity, but long-running transactions can create performance and scalability problems.

A transaction that updates many records, triggers synchronous plug-ins, and performs expensive queries can hold database resources longer than necessary.

Microsoft warns that long-running ExecuteTransactionRequest operations can contribute to database blocking. <Cite ref=”turn913502search4″/>

A useful experiment should therefore measure:

  • Total transaction duration.
  • Number of contained operations.
  • Number of synchronous plug-in executions.
  • Fault position.
  • Rollback outcome.
  • Client-observed latency.

The goal is not to maximize transaction size.

It is to identify the smallest atomic boundary that preserves the required business invariant.

18. Important Constraints

Several documented constraints affect the design.

ExecuteTransactionRequest is intended for atomic operations submitted by external clients. Microsoft advises against using batch request types inside synchronous plug-ins, where an existing transaction may already be active. <Cite ref=”turn913502search4″/>

ExecuteMultipleRequest executes its contained operations independently and can produce partial success. <Cite ref=”turn913502search2″/>

ExecuteTransactionRequest rolls back transactionally included changes when a request fails. <Cite ref=”turn913502search1″/>

Elastic tables require separate consideration. Microsoft documents that elastic-table operations do not provide the same multi-record transaction guarantees as standard tables, including limitations involving ExecuteTransactionRequest. The initial APP-008 experiment therefore explicitly uses standard Dataverse tables.

19. Microsoft Learn Technical References

  1. Execute messages in a single database transaction — Atomic SDK request execution and rollback.
  2. Execute multiple requests using the SDK — Independent batch operations and partial success.
  3. Scalable Customization Design: Database Transactions — Transaction boundaries, plug-ins, and scalability.
  4. Event Framework — Pipeline stages and execution behavior.
  5. Handle exceptions in plug-ins — Exceptions and transaction rollback.
  6. Don’t use batch request types in plug-ins and workflow activities — Server-side batch guidance.
  7. Troubleshoot Dataverse plug-ins — Transaction-related plug-in failures.
  8. Elastic tables for developers — Elastic-table consistency and transaction limitations.

Conclusion

Dataverse transactions are defined by platform execution boundaries, not by the number of operations a developer places next to each other in C#.

Independent service calls can commit independently.

ExecuteMultipleRequest reduces round trips but allows partial success.

ExecuteTransactionRequest provides atomic execution for supported Dataverse operations.

Synchronous plug-ins can participate in an existing database transaction, but external side effects remain outside that transaction.

APP-008 establishes the engineering foundation for investigating transaction scope, rollback, plug-in execution, and distributed consistency.

The central principle is:

Atomicity is a property of an explicitly defined transaction boundary. It must never be assumed across independent requests or external systems.


Power Platform Engineering Academy — Five Years in Five Days

Article 008/100 — Transaction & Rollback Laboratory

Define the boundary. Inject the failure. Inspect persisted state. Verify rollback.

Edvaldo Guimrães Filho Avatar

Published by