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:
- Independent Organization Service requests.
- Batched requests using
ExecuteMultipleRequest. - 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.
| Property | Meaning | Engineering relevance |
|---|---|---|
| Atomicity | All transactionally included changes commit or roll back together | Prevents partial database state |
| Consistency | Transactional changes must satisfy enforced constraints | Preserves defined invariants |
| Isolation | Concurrent transactions interact according to database isolation behavior | Influences locking and concurrency |
| Durability | Committed changes survive subsequent failures | Establishes 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 → CommitCreate Child A → CommitCreate Child B → FailureCreate 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
| Column | Type | Purpose |
|---|---|---|
pea_name | Primary name | Parent identifier |
pea_batchid | Single line of text | Experiment correlation |
pea_description | Multiple lines of text | Scenario description |
pea_amount | Decimal number | Example business value |
4.2 Transaction Child
Logical name: pea_transactionchild
| Column | Type | Purpose |
|---|---|---|
pea_name | Primary name | Child identifier |
pea_batchid | Single line of text | Experiment correlation |
pea_parent | Lookup | Related parent |
pea_sequence | Whole number | Operation order |
pea_amount | Decimal number | Example child value |
Create a one-to-many relationship from pea_transactionparent to pea_transactionchild.
4.3 Transaction Run
Logical name: pea_transactionrun
| Column | Type | Purpose |
|---|---|---|
pea_name | Primary name | Run identifier |
pea_batchid | Single line of text | Correlation key |
pea_executionmode | Single line of text | Independent, Multiple, Transaction |
pea_failureinjected | Yes/No | Whether failure was intentional |
pea_expectedrollback | Yes/No | Expected atomicity |
pea_parentcount | Whole number | Observed surviving parents |
pea_childcount | Whole number | Observed surviving children |
pea_success | Yes/No | Expected outcome matched |
pea_message | Multiple lines of text | Diagnostics |
pea_executedon | Date and time | Execution 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.TransactionRollbackLabcd APP008.TransactionRollbackLabdotnet add package Microsoft.PowerPlatform.Dataverse.Client
The program will execute three scenarios using distinct batch identifiers.
For each scenario, it will:
- Construct the same logical set of operations.
- Inject one deterministic failure.
- Capture the resulting fault.
- Query the surviving parent and child records.
- Compare observed counts with expected counts.
- Persist an experiment run record.
- 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 mode | Parents remaining | Children remaining | Interpretation |
|---|---|---|---|
| Independent | 1 | 2 | Earlier requests committed |
| ExecuteMultiple | 1 | 2 | Partial batch success |
| ExecuteTransaction | 0 | 0 | Atomic 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:
| Stage | Transaction behavior |
|---|---|
| PreValidation | Usually outside the initial operation’s transaction; can participate in an existing transaction |
| PreOperation | Executes within the database transaction |
| MainOperation | Platform 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 record2. Call external REST API3. External API commits an operation4. Plug-in throws exception5. 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:
| Metric | Purpose |
|---|---|
| Total experiment runs | Number of completed experiments |
| Expected rollbacks | Number of atomic failure scenarios |
| Verified rollbacks | Runs with no surviving transactional records |
| Partial commits | Runs with surviving records after failure |
| Failed assertions | Unexpected observed outcomes |
| Latest run | Most 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
- Execute messages in a single database transaction — Atomic SDK request execution and rollback.
- Execute multiple requests using the SDK — Independent batch operations and partial success.
- Scalable Customization Design: Database Transactions — Transaction boundaries, plug-ins, and scalability.
- Event Framework — Pipeline stages and execution behavior.
- Handle exceptions in plug-ins — Exceptions and transaction rollback.
- Don’t use batch request types in plug-ins and workflow activities — Server-side batch guidance.
- Troubleshoot Dataverse plug-ins — Transaction-related plug-in failures.
- 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.
