This document covers all transaction and connection-lifecycle features in ActiveForge: manual
transactions, the IUnitOfWork layer, With.Transaction, the [Transaction] Castle
DynamicProxy interceptor, and DI service proxy registration.
- Manual Transactions
- Optimistic Locking
- Action Queue (Batch Operations)
- Read-for-Update (Advisory Lock)
- Unit of Work — IUnitOfWork
- With.Transaction Helper
- Connection Lifecycle
- TransactionAttribute and Castle DynamicProxy
- ActiveForgeServiceLocator (Ambient DI)
- Logging
- Error Handling
- DI Integration & Service Proxies
- IActiveForgeBuilder — Fluent Registration
- Proxy Strategies
- Standalone Usage (No DI)
- Testing
- NuGet Packages
Wrap multiple operations in a single atomic unit using BeginTransaction /
CommitTransaction / RollbackTransaction directly on DataConnection.
BaseTransaction tx = conn.BeginTransaction();
try
{
var order = new Order(conn);
order.CustomerName.SetValue("Alice Smith");
order.OrderDate.SetValue(DateTime.UtcNow);
order.TotalAmount.SetValue(0m);
order.Insert();
var line = new OrderLine(conn);
line.OrderID.SetValue((int)order.ID.GetValue());
line.ProductID.SetValue(7);
line.Quantity.SetValue(2);
line.UnitPrice.SetValue(29.99m);
line.Insert();
order.TotalAmount.SetValue(59.98m);
order.Update(RecordLock.UpdateOption.IgnoreLock);
conn.CommitTransaction(tx);
}
catch
{
conn.RollbackTransaction(tx);
throw;
}All Record instances bound to the same DataConnection automatically participate
in that connection's active transaction — there is no need to pass the transaction handle
to each entity.
using System.Data;
var tx = conn.BeginTransaction(IsolationLevel.RepeatableRead);
// ... operations ...
conn.CommitTransaction(tx);Supported levels: ReadUncommitted, ReadCommitted (default), RepeatableRead,
Serializable, Snapshot.
TransactionStates state = conn.TransactionState(tx);
// state is one of: None, Active, Committed, RolledBackDBDataConnection tracks a transaction depth counter. Calling BeginTransaction
inside an already-active transaction increments the counter rather than opening a new
database transaction. Only the outermost CommitTransaction or RollbackTransaction
actually issues COMMIT / ROLLBACK to the database.
var outer = conn.BeginTransaction(); // depth: 1 — real BEGIN TRANSACTION
var inner = conn.BeginTransaction(); // depth: 2 — logical only
conn.CommitTransaction(inner); // depth: 1 — no DB commit yet
conn.CommitTransaction(outer); // depth: 0 — real COMMITIf any level rolls back, the entire transaction rolls back.
Update accepts a RecordLock.UpdateOption that controls how the ORM handles
concurrent writes:
| Option | Behaviour |
|---|---|
IgnoreLock |
No locking check — simplest option for non-concurrent tables |
ReleaseLock |
Checks an optimistic lock column and releases it after update |
RetainLock |
Checks an optimistic lock column and keeps the lock for further updates |
// Most common — no concurrent locking needed
product.Price.SetValue(14.99m);
product.Update(RecordLock.UpdateOption.IgnoreLock);
// Optimistic lock — throws ObjectLockException if another writer changed the row
product.Update(RecordLock.UpdateOption.ReleaseLock);Catch ObjectLockException to handle a lost update gracefully:
try
{
product.Update(RecordLock.UpdateOption.ReleaseLock);
}
catch (ObjectLockException)
{
// Another process updated this row — re-read and retry
product.Read();
product.Price.SetValue(14.99m);
product.Update(RecordLock.UpdateOption.ReleaseLock);
}The action queue lets you accumulate INSERT / UPDATE / DELETE operations and flush them all in a single database round-trip, wrapped in an automatic transaction.
var p1 = new Product(conn);
p1.Name.SetValue("Widget A");
p1.Price.SetValue(9.99m);
p1.QueueForInsert();
var p2 = new Product(conn);
p2.Name.SetValue("Widget B");
p2.Price.SetValue(14.99m);
p2.QueueForInsert();
p1.Price.SetValue(7.99m);
p1.QueueForUpdate();
var old = new Product(conn);
old.ID.SetValue(99);
old.QueueForDelete();conn.ProcessActionQueue(); // executes all queued operations atomicallyIf any operation fails, the entire batch is rolled back.
conn.ClearActionQueue(); // discard pending operations without executingvar template = new Product(conn);
var term = new EqualTerm(template, template.InStock, false);
template.QueueForDelete(term); // DELETE FROM Products WHERE InStock = 0
conn.ProcessActionQueue();ReadForUpdate acquires a row-level update lock on SQL Server (SELECT ... WITH (UPDLOCK))
and PostgreSQL (SELECT ... FOR UPDATE) so that no other session can update the row between
your read and your subsequent write:
var product = new Product(conn);
product.ID.SetValue(42);
var tx = conn.BeginTransaction();
conn.ReadForUpdate(product, null); // SELECT ... WITH (UPDLOCK)
product.Price.SetValue(product.Price + 5m);
product.Update(RecordLock.UpdateOption.IgnoreLock);
conn.CommitTransaction(tx); // lock releasedIUnitOfWork is the recommended approach for automatic transaction management across
multiple service methods.
public interface IUnitOfWork : IDisposable
{
bool InTransaction { get; }
BaseTransaction CreateTransaction(IsolationLevel level = IsolationLevel.ReadCommitted);
void Commit();
void Rollback();
}BaseUnitOfWork manages connection lifetime automatically:
- When
CreateTransaction()is called and depth is 0 and the connection is not already open, the UoW opens the connection itself. - When the outermost
Commit()orRollback()completes, the UoW closes the connection — but only if it was the one that opened it. - If the connection was already open before
CreateTransaction()was called (e.g., opened by the caller), the UoW never closes it.
This means you do not need to call Connect() manually when using IUnitOfWork.
BaseUnitOfWork maintains a depth counter:
- Outer call
CreateTransaction()— depth 0 → 1; opens the connection (if not already open) and starts a real ADO.NET transaction. - Inner call
CreateTransaction()— depth 1 → 2; reuses the existing transaction. - Inner
Commit()— depth 2 → 1; no DB action yet. - Outer
Commit()— depth 1 → 0; actually commits, then closes the connection (if owned). - Any
Rollback()— marks the transaction as rollback-only; the outermostCommit()rolls back instead.
Nested transactional methods are safe with no extra configuration.
| Provider | Class | Package |
|---|---|---|
| SQL Server | SqlServerUnitOfWork |
ActiveForge.SqlServer |
| PostgreSQL | PostgreSQLUnitOfWork |
ActiveForge.PostgreSQL |
| MongoDB | MongoUnitOfWork |
ActiveForge.MongoDB |
| SQLite | SQLiteUnitOfWork |
ActiveForge.SQLite |
All other UoW types (IUnitOfWork, BaseUnitOfWork, With, TransactionInterceptor,
ActiveForgeServiceLocator) are in ActiveForge with no provider dependency.
// No Connect() needed — BaseUnitOfWork opens the connection on first CreateTransaction()
var conn = new SqlServerConnection("Server=...;Database=...;...");
using IUnitOfWork uow = new SqlServerUnitOfWork(conn);
uow.CreateTransaction();
try
{
// ORM operations against conn ...
uow.Commit();
}
catch
{
uow.Rollback();
throw;
}var conn = new PostgreSQLConnection("Host=localhost;Database=demo;Username=app;Password=secret;");
using IUnitOfWork uow = new PostgreSQLUnitOfWork(conn);
With.Transaction(uow, () =>
{
var product = new Product(conn);
product.name.SetValue("Widget");
product.price.SetValue(9.99m);
conn.Insert(product);
});var conn = new MongoDataConnection("mongodb://localhost:27017", "demo");
using IUnitOfWork uow = new MongoUnitOfWork(conn);
With.Transaction(uow, () =>
{
var product = new Product(conn);
product.name.SetValue("Widget");
product.price.SetValue(9.99m);
conn.Insert(product);
});Note: MongoDB multi-document transactions require a replica set or sharded cluster. On a standalone server
BeginTransactionwill throw a MongoDB driver error. Single-document operations are inherently atomic without a transaction.
With.Transaction is a static helper that manages the transaction lifecycle for a single
Action or Func<T>.
// Explicit IUnitOfWork
With.Transaction(uow, () => { /* work */ });
With.Transaction(uow, () => { /* work */ }, IsolationLevel.Serializable);
// Resolved via ActiveForgeServiceLocator
With.Transaction(() => { /* work */ });int id = With.Transaction(uow, () =>
{
product.Insert();
return (int)product.ID.GetValue();
});With.SerializableTransaction(uow, () => { /* ... */ });
With.RepeatableReadTransaction(uow, () => { /* ... */ });
With.SnapshotTransaction(uow, () => { /* ... */ });BaseUnitOfWork owns connection lifetime when it opens the connection itself:
uow.CreateTransaction()
→ depth 0 → 1: IsOpen == false → Connect() (connection owned by UoW)
BeginTransactionCore(level) (real ADO.NET transaction)
→ depth 1 → 2: reuse existing transaction
uow.Commit() depth 2 → 1: no DB action
uow.Commit() depth 1 → 0: Commit → Dispose transaction → Disconnect()
If the connection was already open when CreateTransaction() was called, the UoW skips
Connect() and sets _ownedConnection = false. In that case Commit()/Rollback()
never call Disconnect() — the caller keeps ownership of the connection.
Read operations (Read, QueryAll, QueryPage, …) auto-connect and auto-disconnect
per call. Write operations (Insert, Update, Delete, ProcessActionQueue, …) also
auto-connect/disconnect when no ambient transaction is active.
var conn = new SqlServerConnection("...");
// conn.UnitOfWork not set — no auto-transaction, connection auto-managed per call
var product = new Product(conn);
product.ID.SetValue(1);
product.Read(); // connects, reads, disconnects — no transactionAssign conn.UnitOfWork = uow once. Every write then automatically opens the connection,
begins a transaction, commits, and closes — no service proxy required:
var conn = new SqlServerConnection("Server=...;...");
var uow = new SqlServerUnitOfWork(conn);
conn.UnitOfWork = uow;
var product = new Product(conn);
product.Name.SetValue("Widget");
product.Price.SetValue(9.99m);
// UoW opens connection → begins transaction → inserts → commits → closes.
product.Insert();[Transaction] decorates a method (or class) so Castle DynamicProxy automatically wraps
each invocation in an IUnitOfWork transaction.
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class TransactionAttribute : Attribute
{
public IsolationLevel IsolationLevel { get; }
public TransactionAttribute(IsolationLevel level = IsolationLevel.ReadCommitted);
}- Method-level — only that method is intercepted.
- Class-level — every virtual method on the class is intercepted.
- Methods without
[Transaction]that are called while a transaction is active enlist in the ambient transaction automatically (sameIUnitOfWorkdepth counter).
TransactionInterceptor implements Castle.DynamicProxy.IInterceptor:
- If
[Transaction]is present: callsuow.CreateTransaction(level), thenProceed(). - On success: calls
uow.Commit(). - On exception: calls
uow.Rollback()and rethrows. - If no
[Transaction]: delegates directly to the real method.
Nested [Transaction] methods increment the depth counter; only the outermost
Commit/Rollback touches the database.
The TransactionInterceptor does not open or close the connection itself. When
CreateTransaction() is first called (depth 0 → 1), BaseUnitOfWork opens the connection
if it is not already open. The connection is closed when the outermost Commit/Rollback
completes. No separate connection-scope attribute is required.
// Proxy ordering (single interceptor):
Call enters proxy
→ TransactionInterceptor: uow.CreateTransaction() [opens connection + begins tx at depth 0]
→ Real method executes
→ TransactionInterceptor: uow.Commit() (or Rollback on exception)
[at depth 0: commits tx, closes connection]A thin static bridge that lets any DI container back the locator.
// Register once at startup:
ActiveForgeServiceLocator.SetProvider(serviceProvider); // any IServiceProvider
// OR:
ActiveForgeServiceLocator.SetUnitOfWorkFactory(() => new SqlServerUnitOfWork(conn));
// Resolve anywhere (e.g. inside With.Transaction or your own code):
IUnitOfWork uow = ActiveForgeServiceLocator.GetUnitOfWork();
T svc = ActiveForgeServiceLocator.Resolve<T>();Call ActiveForgeServiceLocator.Reset() in test teardown to avoid leaking state between
tests.
BaseUnitOfWork and TransactionInterceptor both accept an ILogger (from
Microsoft.Extensions.Logging.Abstractions). When omitted, NullLogger.Instance is used
silently.
ILogger<SqlServerUnitOfWork> logger = loggerFactory.CreateLogger<SqlServerUnitOfWork>();
IUnitOfWork uow = new SqlServerUnitOfWork(conn, logger);With.Transaction uses a static logger configured via:
With.SetLogger(loggerFactory.CreateLogger("ActiveForge.Transactions.With"));| Situation | Behaviour |
|---|---|
Exception inside With.Transaction |
Automatically rolls back; exception rethrown |
Exception inside [Transaction] method |
Interceptor rolls back; exception rethrown |
Dispose() with open transaction |
Rolls back and logs a warning |
Rollback() inside nested call |
Marks rollback-only; outermost Commit() rolls back instead |
Commit() without active transaction |
Throws InvalidOperationException |
CreateTransaction() after Dispose() |
Throws ObjectDisposedException |
All features below are platform-agnostic — they work in ASP.NET Core, Worker Service,
console, or any host that uses Microsoft.Extensions.DependencyInjection.
// Program.cs (or Startup.ConfigureServices)
builder.Services
.AddActiveForgeSqlServer(
"Server=.;Database=Demo;Integrated Security=True;TrustServerCertificate=True;")
.AddServices(typeof(Program).Assembly); // scans for IService implementations
// PostgreSQL:
builder.Services
.AddActiveForgePostgreSQL("Host=localhost;Database=demo;Username=app;Password=secret;")
.AddServices(typeof(Program).Assembly);
// MongoDB:
builder.Services
.AddActiveForgeMongoDB("mongodb://localhost:27017", "demo")
.AddServices(typeof(Program).Assembly);
// SQLite:
builder.Services
.AddActiveForgeSQLite("Data Source=app.db")
.AddServices(typeof(Program).Assembly);AddServices() scans the given assembly for all non-abstract classes that implement
IService, then registers each one as a scoped service behind its interface(s) — no
manual per-type registration needed.
using ActiveForge;
using ActiveForge.Transactions;
// ── Interface ─────────────────────────────────────────────────────────────────
public interface IOrderService
{
Order GetById(int id);
void Ship(int orderId);
}
// ── Implementation ────────────────────────────────────────────────────────────
// Implements IOrderService (the DI-facing interface) + IService (ActiveForge marker).
// No virtual methods required — the interface proxy handles interception.
public class OrderService : IOrderService, IService
{
private readonly DataConnection _conn;
public OrderService(DataConnection conn) { _conn = conn; }
// No transaction needed — read-only, no UoW required.
public Order GetById(int id)
{
var order = new Order(_conn);
order.ID.SetValue(id);
_conn.Read(order);
return order;
}
// [Transaction] opens the connection, begins a transaction, and closes on completion.
[Transaction]
public void Ship(int orderId)
{
var order = new Order(_conn);
var shipment = new Shipment(_conn);
order.ID.SetValue(orderId);
_conn.Read(order);
order.Status.SetValue("Shipped");
order.Update(RecordLock.UpdateOption.IgnoreLock);
shipment.OrderID.SetValue(orderId);
shipment.Insert();
// Transaction commits here on success; rolls back on exception.
// Connection is closed by the UoW when the outermost Commit completes.
}
}[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IOrderService _orders;
public OrdersController(IOrderService orders) { _orders = orders; }
[HttpGet("{id}")]
public IActionResult Get(int id)
{
var order = _orders.GetById(id);
return order != null ? Ok(order) : NotFound();
}
[HttpPost("{id}/ship")]
public IActionResult Ship(int id)
{
_orders.Ship(id); // [Transaction] proxy opens conn, begins tx, commits/rolls back
return NoContent();
}
}// ActiveForge namespace — import with "using ActiveForge;"
public interface IService { }Implementing IService on a class signals that it should be discovered by AddServices().
No methods or properties are required.
A class may implement multiple non-system interfaces alongside IService — each will be
registered separately in the DI container:
public class ReportService : IReportService, IAuditService, IService
{
// Registered as both IReportService and IAuditService
[Transaction]
public Report GenerateSales() { ... }
}using ActiveForge.Transactions;
[Transaction] // ReadCommitted (default)
[Transaction(IsolationLevel.Serializable)] // explicit isolation
public void Process() { ... }[Transaction] on a service method is sufficient — the TransactionInterceptor opens the
connection (via BaseUnitOfWork.CreateTransaction) and closes it on completion. No
additional attribute is needed.
Attribute placement: when using interface proxies (the default for IService services),
place [Transaction] on the implementation class method (or the class itself).
The interceptor uses IInvocation.MethodInvocationTarget to find the concrete method, so
attributes on the implementation are always detected correctly. Attributes on the interface
are supported as a fallback.
All AddActiveForge* extension methods return IActiveForgeBuilder for chaining:
public interface IActiveForgeBuilder
{
IServiceCollection Services { get; }
// Auto-scan assemblies for IService implementations
IActiveForgeBuilder AddServices(params Assembly[] assemblies);
// Explicit single-service registration
IActiveForgeBuilder AddService<TService>() where TService : class;
IActiveForgeBuilder AddService<TInterface, TImplementation>()
where TInterface : class
where TImplementation : class, TInterface;
}builder.Services
.AddActiveForgeSqlServer("...")
.AddServices(typeof(Program).Assembly,
typeof(SomeLibrary.Marker).Assembly);If no assembly is supplied, Assembly.GetEntryAssembly() is used.
AddServices registers all non-abstract IService implementations it finds. For each:
- All non-system interfaces the class implements (excluding
IServiceitself) → interface proxy - If the class has no qualifying interface → concrete class proxy
When a service does not implement IService:
builder.Services
.AddActiveForgeSqlServer("...")
.AddService<IOrderService, OrderService>() // interface proxy
.AddService<ReportEngine>(); // class proxy (non-sealed + virtual)| Scenario | Proxy type | Requirements |
|---|---|---|
Service interface + IService (auto-scan) |
CreateInterfaceProxyWithTarget |
None — no virtual required |
AddService<TInterface, TImpl>() |
CreateInterfaceProxyWithTarget |
None |
AddService<TClass>() where TClass is a class |
CreateClassProxyWithTarget |
Non-sealed; intercepted methods virtual |
TransactionInterceptor is the sole interceptor registered by the proxy factory. When
IUnitOfWork is not registered in DI, no interceptors are applied and the proxy passes
through directly.
var conn = new SqlServerConnection("Server=...;...");
var uow = new SqlServerUnitOfWork(conn);
var svc = ActiveForgeServiceFactory.Create<IOrderService>(
new OrderService(conn), conn, uow);
svc.Ship(42); // proxy begins transaction, calls Ship(), commits, closes connectionWith.Transaction also works alongside proxied services and shares the same scoped
IUnitOfWork. Configure ActiveForgeServiceLocator once if you want the no-arg overload:
// At startup, after building the container:
ActiveForgeServiceLocator.SetProvider(app.Services);
// Then anywhere (inside a DI scope):
With.Transaction(() =>
{
order.Status.SetValue("Shipped");
order.Update(RecordLock.UpdateOption.IgnoreLock);
});Unit tests can bypass the proxy entirely and call the real service directly:
// Unit test — no proxy needed
var conn = new StubDataConnection();
var svc = new OrderService(conn); // concrete class, no interception
svc.GetById(1);
// Integration test — real proxy against test database
var conn = new SqlServerConnection("Server=.;Database=Test;...");
var uow = new SqlServerUnitOfWork(conn);
var proxy = ActiveForgeServiceFactory.Create<IOrderService>(new OrderService(conn), conn, uow);
proxy.Ship(1);Call ActiveForgeServiceLocator.Reset() in test teardown to avoid leaking state between
tests.
| Package | Purpose |
|---|---|
Castle.Core 5.1.1 |
DynamicProxy runtime for TransactionInterceptor |
Microsoft.Extensions.Logging.Abstractions 8.0.0 |
ILogger abstraction used by BaseUnitOfWork |
Microsoft.Extensions.DependencyInjection.Abstractions 8.0.0 |
DI integration (IActiveForgeBuilder, AddActiveForgeService<T>) |
ActiveForge.SqlServer |
SqlServerUnitOfWork, AddActiveForgeSqlServer() |
ActiveForge.PostgreSQL |
PostgreSQLUnitOfWork, AddActiveForgePostgreSQL() |
ActiveForge.MongoDB |
MongoUnitOfWork, AddActiveForgeMongoDB() |
ActiveForge.SQLite |
SQLiteUnitOfWork, AddActiveForgeSQLite() |
| Scenario | Recommended approach |
|---|---|
| Single entity save | Insert() / Update() / Delete() directly |
| Multiple entities atomically (manual) | BeginTransaction → operations → CommitTransaction |
| Multiple entities atomically (automatic) | With.Transaction(uow, () => { … }) |
| Service method auto-transaction | [Transaction] attribute + Castle DynamicProxy |
| Bulk inserts / updates | QueueForInsert / QueueForUpdate → ProcessActionQueue |
| Preventing lost updates | ReadForUpdate inside a transaction |
| Optimistic concurrency | Update(RecordLock.UpdateOption.ReleaseLock) + catch ObjectLockException |