Protecting Your Data Integrity with Transactions in ADO.NET

 

Christa Carpentiere

May 2004

Applies to:
Microsoft® ADO.NET
Microsoft® .NET Framework

Summary: Transactions are a key feature for maintaining data integrity when modifying data. Get an overview of transactions and their importance, then learn how to use them to protect data in your .NET applications. (19 printed pages)

Contents

Introduction
Anatomy of a Transaction
Transactions with ADO.NET
Implementing Transactions
Conclusion

Introduction

Transactions are a feature offered by most enterprise-class databases for making sure data integrity is maintained when data is modified. Maintaining data integrity protects the quality of the data that your organization relies upon; after all, when you generate a report or engage in some other data-driven procedure, you want to know that the information you are working with is correct.

A successful transaction must pass the "ACID" test, that is, it must be:

  • Atomic – All statements in the transaction either completed successfully or they were all rolled back. The task that the set of operations represents is either accomplished or not, but in any case not left half-done, to the detriment of your data.
  • Consistent – All data touched by the transaction is left in a logically consistent state. For example, if inventory numbers were decremented in one table, there has to be a related order that consumed that inventory. The inventory can't just disappear.
  • Isolated – The transaction must affect data without interfering with other concurrent transactions, or being interfered with by them. This prevents transactions from making changes to data based on uncommitted information, for example changes to a record that are subsequently rolled back. Most databases use locking to maintain transaction isolation.
  • Durable – The data changes enacted by the transaction are permanent, and will persist through a system failure.

The classic example is submitting an order to an order entry system—you have to check the customer's credit level, create an order record, create order line item records, decrement the order items from inventory, create a ship request, and more. If one or more of those steps fail but the rest succeed, you can end up with inconsistent data in the form of orphaned records, unreliable inventory numbers, and so forth. Using transactions appropriately prevents this kind of thing from happening. And that's good for your DBA, your company, and your job security.

That said, remember to only use transactions where necessary. Managing transactions will add some overhead to the system. Using transactions can also increase the number and extent of locks (or other isolation mechanism, depending on the database) used in the system, which can lead to concurrency issues. Consider such things as whether the work encapsulated in the transaction must be performed as an atomic unit, whether there are consequences to it being performed in stages, and what will happen if it only partially completes.

Transactions can be implemented on the server side, using whatever variant of SQL is native to the database, or they can be handled using ADO.NET in the calling application. This article will address implementing database transactions with the ADO.NET data providers. I'll start by taking a look at what transactions are and how they affect your interactions with the database. I'll then take a look at the classes that the .NET Framework provides to assist you in creating and managing transactions in your data access code, and some common scenarios for their use.

Anatomy of a Transaction

A transaction at its most basic level consists of two required steps—Begin, and then either Commit or Rollback. The Begin call defines the start of the transaction boundary, and the call to either Commit or Rollback defines the end of it. Within the transaction boundary, all of the statements executed are considered to be part of a unit for accomplishing the given task, and must succeed or fail as one. Commit (as the name suggests) commits the data modifications if everything was successful, and Rollback undoes the data modifications if an error occurs. All of the .NET data providers provide similar classes and methods to accomplish these operations.

Isolation Levels

Isolation levels are used by transactions to determine how restrictive the transaction is in regards to its isolation behavior. The more restrictive the isolation level, the more stringently the data affected by the transaction is isolated from other transactions. Most databases enforce isolation by means of locking; you should double-check what method your targeted DBMS uses. The tradeoff is between performance and concurrency—the more locks the system has to maintain, the more likely it is that there will be conflicts and slowdowns as different processes try to access the same resources.

The following is a list of the isolation levels supported in ADO.NET, taken from the .NET Framework SDK. The isolation levels are listed from least restrictive to most restrictive:

Member name Description Value
Unspecified

Supported by the .NET Compact Framework.

A different isolation level than the one specified is being used, but the level cannot be determined. -1
Chaos

Supported by the .NET Compact Framework.

The pending changes from more highly isolated transactions cannot be overwritten. 16
ReadUncommitted

Supported by the .NET Compact Framework.

A dirty read is possible, meaning that no shared locks are issued and no exclusive locks are honored. 256
ReadCommitted

Supported by the .NET Compact Framework.

Shared locks are held while the data is being read to avoid dirty reads, but the data can be changed before the end of the transaction, resulting in non-repeatable reads or phantom data. 4096
RepeatableRead

Supported by the .NET Compact Framework.

Locks are placed on all data that is used in a query, preventing other users from updating the data. Prevents non-repeatable reads but phantom rows are still possible. 65536
Serializable

Supported by the .NET Compact Framework.

A range lock is placed on the data set, preventing other users from updating or inserting rows into the dataset until the transaction is complete. 1048576

Note:   The Chaos isolation level is not supported by SQL Server or Oracle, and only the OLE DB data provider will accept it as a valid isolation level to set on a transaction. Attempting to set it for the SQL Server or Oracle provider will kick up an ArgumentException indicating an invalid IsolationLevel parameter.

The SQL Server, Oracle, and OLE DB data providers all default to the ReadCommitted isolation level. The ODBC data provider isolation level default varies, depending on the ODBC driver used. If you think ReadCommitted will not provide the isolation level you want for a given transaction, you can use the Transaction.IsolationLevel property to specify a different level. You should think very carefully about changing this setting, though, and perform adequate testing to ensure that the change gives you the behavior that you want without negative side effects like data inconsistency on one hand or concurrency issues on the other. If you have a database administrator, you should definitely consult with her about the appropriate level to use for a given task. You should also consult your database documentation to see exactly how your target database handles isolation levels and locking.

Partial Rollbacks

Occasionally you'll find a situation where you want to rollback some but not all of a transaction if an error arises. Generally you'll want to do this only when you expect the process to complete successfully the great majority of the time, and checking for success conditions for some part of it beforehand would be expensive. You are balancing the cost of doing the data modification and then having to do a rollback some small percentage of the time against the benefit of avoiding the expense of the verification routine all of the time.

Partial rollbacks are usually implemented by using either savepoints or nested transactions, depending on the database you are working with. Some DBMSs don't offer either mechanism, so consult your database documentation to see whether and how partial rollbacks are implemented.

SQL Server and Oracle allow the use of named savepoints that you can refer to when issuing a rollback, to stop the rollback at a predetermined point. They do not permit true nested transactions, which some other DBMSs use to accomplish the same task. SQL Server allows nested transactions in the simplest sense, in that you can nest BEGIN…COMMIT blocks in T-SQL. However, only the outer COMMIT will actually commit all of the transactions; the inner COMMITs only decrement a system variable so you can keep track of how many open transactions you have.

Some databases do permit true nested transactions. For these types of databases, inner COMMITs will actually commit the corresponding transaction, and even if the outer transaction is rolled back the inner one will remain committed.

Transactions with ADO.NET

ADO.NET supports two transaction models, which are defined in the .NET Framework docs as manual and automatic. Manual transactions map to what are commonly known as local or database transactions, which affect operations targeting a single database. A class can provide manual transactions by using the transaction classes and methods provided by ADO.NET to explicitly define transaction boundaries. I'll focus on manual (database) transactions in the following sections.

Automatic transactions are also available, to enable a class to enlist and participate in a distributed transaction that coordinates changes across multiple data sources. The transaction itself in such a case is handled externally, for example by COM+ and the Distributed Transaction Manager (DTC). To learn more about automatic transactions, see Automatic Transactions.

Note:   You should also be aware of the default database transaction behavior when none is specified in your code. For example, SQL Server defaults to autocommit mode, where each Transact-SQL statement is either committed (if successful) or rolled back (if not) as soon as it completes. Knowing what type of default behavior to expect from the data source you are working with can help you determine whether you need to define transactions explicitly, and also can be more generally helpful in understanding your system's behavior.

The ADO.NET data providers offer transaction functionality through the Connection, Command, and Transaction classes. A typical transaction would follow a process similar to this:

  1. Open the transaction using Connection.BeginTransaction().
  2. Enlist statements or stored procedure calls in the transaction by setting the Command.Transaction property of the Command objects associated with them.
  3. Depending on the provider, optionally use Transaction.Save() or Transaction.Begin() to create a savepoint or a nested transaction to enable a partial rollback.
  4. Commit or roll back the transaction using Transaction.Commit() or Transaction.Rollback().

All of the data providers offer the same basic structure for handling transactions—begin the transaction off of the connection, enlist the commands, use the Transaction object to commit or rollback—but with some slight tweaks in each case to correspond with the functionality offered by the databases they target.

SQL Server Transactions

The SQL Server data provider provides transaction functionality through the following classes and members:

Class Member Function
SqlConnection BeginTransaction Open a new transaction.
SqlCommand Transaction Enlist the command associated with the object in an open transaction.
SqlTransaction Save Create a savepoint to enable a partial rollback.
SqlTransaction Rollback Roll back a transaction.
SqlTransaction Commit Commit a Transaction

The SQL Server data provider (and the OLE DB Provider for SQL Server as well) does not provide a way to construct nested transactions in code, since nested transactions are not truly supported in SQL Server, as discussed in the "Partial Rollbacks" section above. Therefore there is only one way of beginning a transaction in SqlClient—using SqlConnection.BeginTransaction(). You cannot open another transaction on the same connection until the prior one has been committed or rolled back, so there is always only one open transaction per connection at any given time. To partially roll back a transaction, use SqlTransaction.Save().

OLE DB Transactions

The OLE DB data provider provides transaction functionality through the following classes and members:

Class Member Function
OleDbConnection BeginTransaction Open a new transaction.
OleDbCommand Transaction Enlist the command associated with the object in an open transaction.
OleDbTransaction Begin Create a nested transaction, to enable a partial rollback.
OleDbTransaction Rollback Roll back a transaction.
OleDbTransaction Commit Commit a Transaction

The OLE DB data provider does not offer any way to set savepoints, but instead has a Begin() method on the OleDbTransaction object. This allows you to create nested transactions (provided they are supported by your targeted database) to provide similar functionality. In this case, you can have multiple transactions open on a single connection, and you must remember to explicitly commit or rollback each one.

Keep in mind that the behavior of the nested transactions with the .NET OLE DB data provider will depend on the behavior of both your target database and the OLE DB provider that you specify. Nested transactions must be supported by both in order to be successfully implemented.

Oracle Transactions

The Oracle data provider provides transaction functionality through the following classes and members:

Class Member Function
OracleConnection BeginTransaction Open a new transaction.
OracleCommand Transaction Enlist the command associated with the object in an open transaction.
OracleTransaction Rollback Roll back a transaction.
OracleTransaction Commit Commit a Transaction

The Oracle provider does not allow you to specify either savepoints or nested transactions. Savepoints are supported in Oracle, but you cannot implement them in code using the current version of the Oracle data provider. Transactions against an Oracle database must either rollback or commit completely; partial rollbacks are not an enabled scenario.

Implementing Transactions

Now that we've reviewed what classes and members are involved, let's take a look at a basic implementation. The following code illustrates a simple scenario; using a transaction to ensure that two stored procedures—one that removes inventory from a table, and one that adds inventory to another—execute together or not at all:

using System;
using System.Drawing;
using System.Collections;
using System.ComponentModel;
using System.Windows.Forms;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;

…public void SPTransaction(int partID, int numberMoved, int siteID)
{
   // Create and open the connection.
   SqlConnection conn = new SqlConnection();
   string connString = "Server=SqlInstance;Database=Test;"
      + "Integrated Security=SSPI";
   conn.ConnectionString = connString;
   conn.Open();

   // Create the commands and related parameters.
   // cmdDebit debits inventory from the WarehouseInventory 
   // table by calling the DebitWarehouseInventory 
   // stored procedure.
   SqlCommand cmdDebit = 
      new SqlCommand("DebitWarehouseInventory", conn);
   cmdDebit.CommandType = CommandType.StoredProcedure;
   cmdDebit.Parameters.Add("@PartID", SqlDbType.Int, 0, "PartID");
   cmdDebit.Parameters["@PartID"].Direction = 
      ParameterDirection.Input;
   cmdDebit.Parameters.Add("@Debit", SqlDbType.Int, 0, "Quantity");
   cmdDebit.Parameters["@Debit"].Direction = 
ParameterDirection.Input;

   // cmdCredit adds inventory to the SiteInventory 
   // table by calling the CreditSiteInventory 
   // stored procedure.
   SqlCommand cmdCredit = 
new SqlCommand("CreditSiteInventory", conn);
   cmdCredit.CommandType = CommandType.StoredProcedure;
   cmdCredit.Parameters.Add("@PartID", SqlDbType.Int, 0, "PartID");
   cmdCredit.Parameters["@PartID"].Direction = 
ParameterDirection.Input;
   cmdCredit.Parameters.Add
("@Credit", SqlDbType.Int, 0, "Quantity");
   cmdCredit.Parameters["@Credit"].Direction = 
ParameterDirection.Input;
   cmdCredit.Parameters.Add("@SiteID", SqlDbType.Int, 0, "SiteID");
   cmdCredit.Parameters["@SiteID"].Direction = 
ParameterDirection.Input;

   // Begin the transaction and enlist the commands.
   SqlTransaction tran = conn.BeginTransaction();
   cmdDebit.Transaction = tran;
   cmdCredit.Transaction  = tran;

   try
   {
      // Execute the commands.
      cmdDebit.Parameters["@PartID"].Value = partID;
      cmdDebit.Parameters["@Debit"].Value = numberMoved;
      cmdDebit.ExecuteNonQuery();

      cmdCredit.Parameters["@PartID"].Value = partID;
      cmdCredit.Parameters["@Credit"].Value = numberMoved;
      cmdCredit.Parameters["@SiteID"].Value = siteID;
      cmdCredit.ExecuteNonQuery();

      // Commit the transaction.
      tran.Commit();
   }
   catch(SqlException ex)
   {
      // Roll back the transaction.
      tran.Rollback();

      // Additional error handling if needed.
   }
   finally
   {
      // Close the connection.
conn.Close();
   }
}

As you can see, a nice way to handle transactions in .NET code is to put the Commit() in a try block, using Rollback() in a catch block to undo the changes in case of error.

Using Transactions with a DataAdapter

It is also easy to implement transactions so that they can be used with a DataAdapter.Update() call, whether you choose to create your INSERT/UPDATE/DELETE statements manually or use a CommandBuilder. You just need to set the Command.Transaction property to the appropriate Transaction object:

using System;
using System.Drawing;
using System.Collections;
using System.ComponentModel;
using System.Windows.Forms;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;

…public void CBTransaction()
{
   // Create and open the connection.
   SqlConnection conn = new SqlConnection();
   string connString = "Server= SqlInstance;Database=Test;"
      + "Integrated Security=SSPI";
   conn.ConnectionString = connString;
   conn.Open();
         
   // Create the DataAdapters.
   string cmdString = "Select WIID, PartID, Quantity "
      + "from WarehouseInventory";
   SqlDataAdapter daWarehouse = 
      new SqlDataAdapter(cmdString,conn);

   cmdString = "Select SiteID, PartID, Quantity "
      + "from SiteInventory";
   SqlDataAdapter daSite = 
      new SqlDataAdapter(cmdString,conn);

   // Create the DataSet.
   DataSet ds = new DataSet();

   // Create the CommandBuilders and generate
   // the INSERT/UPDATE/DELETE commands.
   SqlCommandBuilder cbWarehouse = 
      new SqlCommandBuilder(daWarehouse);
   SqlCommand warehouseDelete = cbWarehouse.GetDeleteCommand();
   SqlCommand warehouseInsert = cbWarehouse.GetInsertCommand();
   SqlCommand warehouseUpdate = cbWarehouse.GetUpdateCommand();

   SqlCommandBuilder cbSite = new SqlCommandBuilder(daSite);
   SqlCommand siteDelete = cbSite.GetDeleteCommand();
   SqlCommand siteInsert = cbSite.GetInsertCommand();
   SqlCommand siteUpdate = cbSite.GetUpdateCommand();

   // Fill the DataSet.
   daWarehouse.Fill(ds, "WarehouseInventory");
   daSite.Fill(ds, "SiteInventory");

   // Begin the transaction and enlist the commands.
   SqlTransaction tran = conn.BeginTransaction();
   warehouseDelete.Transaction = tran;
   warehouseInsert.Transaction = tran;
   warehouseUpdate.Transaction = tran;
   siteDelete.Transaction = tran;
   siteInsert.Transaction = tran;
   siteUpdate.Transaction = tran;

   // Modify data to move inventory
   // from WarehouseInventory to SiteInventory.
            
   try
   {
      //Execute the commands
      daWarehouse.Update(ds, "WarehouseInventory");
      daSite.Update(ds, "SiteInventory");

      //Commit the transaction
      tran.Commit();
   }
   catch(SqlException ex)
   {
      //Roll back the transaction.
      tran.Rollback();

      //Additional error handling if needed.
   }
   finally
   {
      // Close the connection.
      conn.Close();
   }
}

Using Savepoints

When using savepoints, it is important to remember that simply rolling back to the savepoint isn't enough. The transaction must still be committed after it is partially rolled back. Also, it is important to plan for how you will notify the user or otherwise handle partially completed transactions if you choose to implement them.

The following code sample illustrates a scenario where you are creating a new customer, and also handling the customer's request for some materials that your company provides. Let's say that you have to perform this operation over a slow connection, or for some other reason the cost of checking the inventory levels prior to the operation is prohibitive. 99.9% of the time, the whole process should complete successfully. However, if the request for materials violates a constraint that maintains minimum inventory levels in the SiteInventory table, it will fail. In that case, the outer catch block will partially roll back and then commit the transaction, thereby saving the new customer record. The inventory issue and the request for materials will then need to be handled in some way. The nested catch block rolls back the whole transaction if for some reason the partial rollback and commit sequence fails.

using System;
using System.Drawing;
using System.Collections;
using System.ComponentModel;
using System.Windows.Forms;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;

…public void SavepointTransaction()
{
   // Create and open the connection.
   SqlConnection conn = new SqlConnection();
   string connString = "Server= SqlInstance;Database=Test;"
      + "Integrated Security=SSPI";
   conn.ConnectionString = connString;
   conn.Open();

   // Create the commands.
   // cmdInsertCustomer creates a new customer record 
   // by calling the DebitWarehouseInventory 
   // stored procedure.
   SqlCommand cmdInsertCustomer = 
      new SqlCommand("CreateCustomer", conn);
   cmdInsertCustomer.CommandType = CommandType.StoredProcedure;
   cmdInsertCustomer.Parameters.Add
      ("@FirstName", SqlDbType.NVarChar, 50, "FirstName");
   cmdInsertCustomer.Parameters.Add
      ("@LastName", SqlDbType.NVarChar, 50, "LastName");
   cmdInsertCustomer.Parameters.Add
      ("@Email", SqlDbType.NVarChar, 50, "Email");
   cmdInsertCustomer.Parameters.Add("@CID", SqlDbType.Int, 0);
   cmdInsertCustomer.Parameters["@FirstName"].Direction = 
      ParameterDirection.Input;
   cmdInsertCustomer.Parameters["@LastName"].Direction = 
      ParameterDirection.Input;
   cmdInsertCustomer.Parameters["@Email"].Direction = 
      ParameterDirection.Input;
   cmdInsertCustomer.Parameters["@CID"].Direction = 
      ParameterDirection.Output;

   // cmdRequestMaterials creates a pick list
   // of the materials requested by the customer
   // by calling the InsertMaterialsRequest 
   // stored procedure.
   SqlCommand cmdRequestMaterials = 
      new SqlCommand("InsertMaterialsRequest", conn);
   cmdRequestMaterials.CommandType = CommandType.StoredProcedure;
   cmdRequestMaterials.Parameters.Add
      ("@CustomerID", SqlDbType.Int, 0, "CustomerId");
   cmdRequestMaterials.Parameters.Add
      ("@RequestPartID", SqlDbType.Int, 0, "PartId");
   cmdRequestMaterials.Parameters.Add
      ("@Number", SqlDbType.Int, 0, "NumberRequested");
   cmdRequestMaterials.Parameters["@CustomerID"].Direction = 
      ParameterDirection.Input;
   cmdRequestMaterials.Parameters["@RequestPartID"].Direction = 
      ParameterDirection.Input;
   cmdRequestMaterials.Parameters["@Number"].Direction = 
      ParameterDirection.Input;

   // cmdUpdateSite debits the requested materials
   // from the inventory of those available by calling
   // the UpdateSiteInventory stored procedure.
   SqlCommand cmdUpdateSite = 
      new SqlCommand("UpdateSiteInventory", conn);
   cmdUpdateSite.CommandType = CommandType.StoredProcedure;
   cmdUpdateSite.Parameters.Add
      ("@SiteID", SqlDbType.Int, 0, "SiteID");
   cmdUpdateSite.Parameters.Add
      ("@SitePartID", SqlDbType.Int, 0, "PartId");
   cmdUpdateSite.Parameters.Add
      ("@Debit", SqlDbType.Int, 0, "Debit");
   cmdUpdateSite.Parameters["@SiteID"].Direction = 
      ParameterDirection.Input;
   cmdUpdateSite.Parameters["@SitePartID"].Direction = 
      ParameterDirection.Input;
   cmdUpdateSite.Parameters["@Debit"].Direction = 
      ParameterDirection.Input;
         
   // Begin the transaction and enlist the commands.
   SqlTransaction tran = conn.BeginTransaction();
   cmdInsertCustomer.Transaction = tran;
   cmdUpdateSite.Transaction  = tran;
   cmdRequestMaterials.Transaction  = tran;

   try
   {
      // Execute the commands.
      cmdInsertCustomer.Parameters["@FirstName"].Value
         = "Mads";
      cmdInsertCustomer.Parameters["@LastName"].Value
         = "Nygaard";
      cmdInsertCustomer.Parameters["@Email"].Value
         = "MadsN@AdventureWorks.com";
      cmdInsertCustomer.ExecuteNonQuery();

      tran.Save("Customer");

      cmdRequestMaterials.Parameters["@CustomerID"].Value
         = cmdInsertCustomer.Parameters["@CID"].Value;
      cmdRequestMaterials.Parameters["@RequestPartID"].Value
         = 3;
      cmdRequestMaterials.Parameters["@Number"].Value
         = 22;
      cmdRequestMaterials.ExecuteNonQuery();

      cmdUpdateSite.Parameters["@SitePartID"].Value
         = 3;
      cmdUpdateSite.Parameters["@Debit"].Value
         = 22;
      cmdUpdateSite.Parameters["@SiteID"].Value
         = 4;
      cmdUpdateSite.ExecuteNonQuery();

      // Commit the transaction.
      tran.Commit();

   }
   catch(SqlException sqlEx)
   {
      try
      {
         // Roll back the transaction
         // to the savepoint.
         Console.WriteLine(sqlEx.Message);
         tran.Rollback("Customer");
         tran.Commit();

         // Add code to notify user or otherwise handle
         // the fact that the procedure was only 
         // partially successful.
      }
      catch(SqlException ex)
      {
         // If the partial rollback fails,
         // roll back the whole transaction.
         Console.WriteLine(ex.Message);
         tran.Rollback();

         // Additional error handling if needed.
      }
   }
   finally
   {
      // Close the connection.
      conn.Close();
   }
}

Using Nested Transactions

Nested transactions require the same considerations as savepoints, as far as making sure any partial rollbacks still commit, and handling any partially completed processes.

The following code sample illustrates a scenario where you are creating a new order, and also creating a pick list for selection of the ordered items from the warehouse. If the creation of the pick list fails—maybe the PartID is invalid for the selected location—you still want the order to be entered, and then have the pick list generated in some other fashion. The nested try/catch block encapsulates the nested transaction for the pick list processing, permitting this inner transaction to commit or fail independent of the outer transaction. The outer catch block rolls back the whole transaction if the order creation process fails.

using System;
using System.Drawing;
using System.Collections;
using System.ComponentModel;
using System.Windows.Forms;
using System.Data;
using System.Data.OleDb;
using System.Text;
…public void NestedTransaction(string UID, string pwd)
{
   // Create and open the connection.
   OleDbConnection conn = new OleDbConnection();
   StringBuilder sb = new StringBuilder();
   sb.Append("Jet OLEDB:System database=");
            sb.Append(@"C:\Databases\system.mdw;");
            sb.Append(@"Data Source=C:\Databases\orders.mdb;");
            sb.Append("Provider=Microsoft.Jet.OLEDB.4.0;");
            sb.Append("User ID=" + UID + ";Password=" + pwd);
   string connString = sb.ToString();
   conn.ConnectionString = connString;
   conn.Open();

   // Create the commands.
   string cmdString = "Insert into Orders"
      + " (OrderID, OrderDate, CustomerID)"
      + " values('ABC60', #4/14/04#, 456)"; 
   OleDbCommand cmdInsertOrder = new OleDbCommand(cmdString,conn);

   //No need to insert OrderLineID, as that is an AutoNumber field.
   cmdString = "Insert into OrderLines (OrderID, PartID, Quantity)"
      + " values('ABC60', 25, 10)";
   OleDbCommand cmdInsertOrderLine = 
new OleDbCommand(cmdString,conn);

   cmdString = "Insert into PickList (OrderID, PartID, Quantity)"
      + " values('ABC60', 25, 10)";
   OleDbCommand cmdCreatePickList = 
new OleDbCommand(cmdString,conn);

   // Begin the outer transaction and 
   // enlist the order-related commands.
      OleDbTransaction tran = conn.BeginTransaction();
   cmdInsertOrder.Transaction = tran;
   cmdInsertOrderLine.Transaction = tran;
            
   try
   {
      // Execute the commands
      // to create the order and order
      // line items.
      cmdInsertOrder.ExecuteNonQuery();
      cmdInsertOrderLine.ExecuteNonQuery();

      // Create a nested transaction
      // that allows the pick list
      // creation to succeed or fail
      // separately if necessary.
      OleDbTransaction nestedTran = tran.Begin();
            
      // Enlist the pick list command.
      cmdCreatePickList.Transaction = nestedTran;

      try
      {

         // Execute the pick list command.
         cmdCreatePickList.ExecuteNonQuery();

         // Commit the nested transaction.
         nestedTran.Commit();
      }
      catch(OleDbException ex)
      {
         //Roll back the transaction.
         nestedTran.Rollback();

         // Add code to notify user or otherwise handle
         // the fact that the procedure was only 
         // partially successful.
      }

      // Commit the outer transaction.
      tran.Commit();
   }
   catch(OleDbException ex)
   {
      //Roll back the transaction.
      tran.Rollback();

      //Additional error handling if needed.
   }
   finally
   {
      // Close the connection.
      conn.Close();
   }
}

Note:   The Jet provider is the only Microsoft OLE DB provider that supports nested transactions.

Conclusion

Transactions are a powerful way to ensure that data modification tasks get handled appropriately, and ADO.NET gives you the ability to easily implement them in your data access code. Think about them when you are building your data access solution and use them where necessary, and you'll help ensure the integrity of the data you work with.