(Not) Upgrading to System.Transactions

We have a homebrew transaction management. We might want to use the built-in .net transactions.

This is my take on this subject after a careful review.

What “transactions” mean in Origam

A transaction is a logical unit of work that needs to be all‑or‑nothing: either all operations succeed or they’re all undone. In Origam this can involve several kinds of resources: a database, files on disk, work‑queue messages, or emails. To make sure those operations stay in sync, Origam assigns a transaction identifier to each unit of work. Every service call (IServiceAgent) carries this TransactionId so that downstream components know which transaction they’re part of.

How Origam implements transactions today

Origam uses its own transaction manager called ResourceMonitor. When a transaction begins, each resource that needs to participate (database, file lock/delete, email fetch, etc.) registers an object derived from OrigamTransaction. These objects are stored in an ordered dictionary keyed by resource ID. The manager supports three basic operations:

  • Commit. When we want to finalize the transaction, the manager loops through the registered OrigamTransaction objects in reverse order of registration and calls their Commit method. If any commit throws an exception, it triggers a rollback of the entire transaction.

    :warning: There is no guarantee that the database commit runs first or last; it depends on the order in which resources were registered.

  • Rollback. On rollback, the manager loops through all registered transactions and calls each one’s Rollback method. It also cleans up the internal transaction store so that stale transactions don’t hang around.
  • Save points. There is also support for save‑points, but those are outside the scope of this discussion.

Each resource implements its own commit and rollback. For example, FileDeleteTransaction holds a list of open file streams; when Commit runs, it closes each stream and permanently deletes the file. Its Rollback just closes the streams; it cannot restore a deleted file.

:warning: This illustrates the current limitation: if a file is deleted and then a later commit fails, Origam can’t bring that file back.

Why I thought about it

TransactionScope is a .NET API that hides transaction details: you wrap code in a using block and the framework manages commits and rollbacks for you. In theory it can also coordinate multiple resources using a two‑phase commit (2PC) protocol via Microsoft’s Distributed Transaction Coordinator (MSDTC). The original idea behind suggesting TransactionScope was:

  • Less custom code. Maybe we could delete our home‑grown transaction manager and rely on a standard library.
  • Automatic 2PC. Perhaps we would get atomic commits “for free” even across different resources.
  • Transactional file system. Microsoft used to have an implementation of file system transactions and I thought we would benefit from it. Unfortunately it’s not the case anymore. It turns out we had file transactions both before and after Microsoft.

Why I am rethinking that

After exploring both the code and the .NET behaviour, several issues became clear:

  1. Ambient vs. explicit context. TransactionScope uses ambient transactions – a hidden context (Transaction.Current) that flows implicitly. This means that when you look at a method signature, you have no idea whether it is running in a transaction. With Origam’s explicit TransactionId, every caller and callee knows exactly which transaction they’re in.
  2. Debugging. Because we use explicit identifiers, logs and diagnostics can always show which transaction a message belongs to. With TransactionScope, the transaction ID is buried in the call context; correlating logs becomes much harder.
  3. Non‑database resources. Many Origam operations (file locks, file deletes, emails) can’t enlist in a .NET transaction out of the box. To work with TransactionScope, each resource would need to implement the IEnlistmentNotification interface and handle Prepare, Commit, Rollback, and InDoubt callbacks. That is a non‑trivial refactor.
  4. Two‑phase commit is not a silver bullet. True 2PC can coordinate multiple durable resources, but it requires a transaction coordinator (MSDTC) and changes the failure modes. Even with 2PC, if a participant makes an irreversible change during the prepare phase, a coordinator failure can leave resources inconsistent. Our file‑deletion example shows that simply calling Delete() cannot be reversed; 2PC would force us to implement a “prepare” stage that only moves or locks files rather than deleting them outright.
  5. Large code change, little benefit. Adopting TransactionScope would not be a drop-in change. It would require rewriting the transaction manager and updating every resource to enlist properly. For our use‑case (one primary database plus a few local resources) this complexity doesn’t buy us much. We would still need to deal with irreversible side‑effects manually.

A better plan: add a prepare/commit phase to Origam

Rather than replacing our transaction manager, we can extend it to mimic the most valuable part of 2PC in case the need comes:

  1. Add a Prepare method to OrigamTransaction. In the prepare phase, each resource performs only reversible actions. For example, instead of immediately deleting a file, the FileDeleteTransaction could move it to a “pending deletion” location. If any prepare fails, we immediately roll back and no irreversible actions have taken place.
  2. Commit only after all resources prepare. Once all participants indicate they are ready, the manager calls their Commit methods. At this point, irreversible actions (e.g. deleting the file) can take place. As we do not have access to the Prepare() method of our database transaction, we would have to make sure we Commit the database transaction first (as the database engine makes sure it preserves atomicity) and only then finally commit all our own resources (files, e-mails). See Commit method
  3. Rollback on failure. If any commit fails, we call Rollback on all participants. Because the prepare phase avoided irreversible changes, rollback can always undo the prepare work. See Rollback method

This small change would make transaction boundaries safer without requiring us to adopt the complexity of distributed transactions. It keeps our transaction identifiers explicit, preserves our current debugging simplicity, and avoids hidden ambient context and huge code rewrites.

But as the only place where mail and file transactions are used are basically work queues where the transactional behavior is covered, we might not just change much or anything at all. If we would e.g. like to introduce transactions to outputting files, we would need much more robust 2PC, with a transaction log and recovery after instance crash.

Summary

In summary, Origam’s existing transaction system is simple, explicit and tuned to our needs. While the .NET TransactionScope API is attractive in theory, it’s not a good fit for our mixed environment of databases, files and email. We can achieve the safety we need by adding a prepare phase to our own transaction manager and ensuring that resources don’t do anything irreversible until they’re sure the overall transaction will commit. This gives us the practical benefits of two‑phase commit without the complexity and dependencies that come with TransactionScope.