Before reading: for a basic understanding of external events, see the previous article on Revit External Events.
Transaction handling in Revit is not complex — but it's tedious when you need a separate IExternalEventHandler for every individual transaction. There is no reason to define one per operation when a generic worker can accept an Action<Document> and handle any transaction on demand.
This solution uses two approaches in parallel — choose either or combine them:
- TransactionUnits folder — an experiment where raising an event creates a new transaction with a custom action inside (registering the action in the Revit context).
- TransactionHandling folder — the worker pool described in this article.

TransactionManager
The manager owns the worker pool and a concurrent queue of pending TransactionUnits.ExecuteInternal is called both when an item is enqueued and when a worker finishes — so if no worker was available on enqueue, the next release will pick it up automatically:
public class TransactionManager
{
private readonly ExternalEventWorkerPool _workerPool;
private readonly ConcurrentQueue<TransactionUnit> _queue = new();
public TransactionManager(int workerCount = 4)
{
_workerPool = new ExternalEventWorkerPool(workerCount, ReleaseUnit);
}
public void Enqueue(TransactionUnit unit)
{
_queue.Enqueue(unit);
ExecuteInternal();
}
// Called when a queue item is added AND when a worker finishes
private void ReleaseUnit(TransactionUnit unit) => ExecuteInternal();
private void ExecuteInternal()
{
if (_queue.IsEmpty) return;
var worker = _workerPool.TryGetWorker();
if (worker == null) return; // no worker available — it will retry on next release
if (_queue.TryDequeue(out var unit))
worker.Setup(unit);
}
}
// TransactionUnit — descriptor that carries the action to execute inside a transaction
public class TransactionUnit
{
public string Name { get; }
public Action<Document> Action { get; }
public TransactionUnit(string name, Action<Document> action)
{
Name = name;
Action = action;
}
}ExternalEventWorkerPool
The pool maintains two lists — available and in-use. When a worker is requested it moves to the in-use list; when the transaction completes it moves back. The ReleaseUnit action (passed to each worker) is also triggered on completion, which re-invokes ExecuteInternal to drain the queue:
public class ExternalEventWorkerPool
{
private readonly List<ExternalEventWorker> _available = new();
private readonly List<ExternalEventWorker> _inUse = new();
private readonly object _lock = new();
public ExternalEventWorkerPool(int count, Action<TransactionUnit> releaseUnit)
{
for (int i = 0; i < count; i++)
_available.Add(new ExternalEventWorker(releaseUnit, OnWorkerReleased));
}
public ExternalEventWorker TryGetWorker()
{
lock (_lock)
{
if (!_available.Any()) return null;
var worker = _available[0];
_available.RemoveAt(0);
_inUse.Add(worker);
return worker;
}
}
// Called by the worker after its transaction completes
private void OnWorkerReleased(ExternalEventWorker worker)
{
lock (_lock)
{
_inUse.Remove(worker);
_available.Add(worker);
}
}
}ExternalEventWorker
Each worker owns one ExternalEventHandler and one ExternalEvent.Setup stores the unit and raises the Revit event, which Revit will execute on its own thread:
public class ExternalEventWorker
{
private readonly ExternalEventHandler _handler;
private readonly ExternalEvent _externalEvent;
private readonly Action<TransactionUnit> _releaseUnit;
private readonly Action<ExternalEventWorker> _workerRelease;
public ExternalEventWorker(
Action<TransactionUnit> releaseUnit,
Action<ExternalEventWorker> workerRelease)
{
_releaseUnit = releaseUnit;
_workerRelease = workerRelease;
_handler = new ExternalEventHandler();
_externalEvent = ExternalEvent.Create(_handler);
}
// Called by TransactionManager.ExecuteInternal — sets up the action then raises the Revit event
public void Setup(TransactionUnit unit)
{
_handler.Setup(unit, OnCompleted);
_externalEvent.Raise();
}
private void OnCompleted(TransactionUnit unit)
{
_releaseUnit(unit); // triggers ExecuteInternal to dequeue the next item
_workerRelease(this); // returns this worker to the available pool
}
}ExternalEventHandler
This is the class Revit actually calls. One handler is assigned per worker, and itsTransactionUnit is set by the TransactionManager → ExternalEventWorker.Setup chain before the event is raised:
// IExternalEventHandler runs in Revit's context — safe to open transactions here
public class ExternalEventHandler : IExternalEventHandler
{
private TransactionUnit _unit;
private Action<TransactionUnit> _onCompleted;
public void Setup(TransactionUnit unit, Action<TransactionUnit> onCompleted)
{
_unit = unit;
_onCompleted = onCompleted;
}
public void Execute(UIApplication app)
{
var doc = app.ActiveUIDocument.Document;
using var transaction = new Transaction(doc, _unit.Name);
transaction.Start();
_unit.Action(doc);
transaction.Commit();
_onCompleted(_unit);
}
public string GetName() => nameof(ExternalEventHandler);
}