Architecture
This page describes how DispatchProxy, TransactionScope, and AsyncLocal fit together inside Gsag.Transactional.Core.
Invocation flow
Caller → IFoo.MethodAsync()
└─ TransactionProxy<T>.Invoke()
├─ attribute cache lookup
│ └─ [Transactional] absent → call target directly, return
│
└─ [Transactional] present:
├─ BeginScope() (AsyncLocal hook stack)
├─ new TransactionScope() (ambient transaction)
├─ observer.OnBegin()
├─ InvokeTarget()
│
├─ [success]
│ ├─ BeforeCommit hooks
│ ├─ scope.Complete()
│ ├─ scope.Dispose() ← committed
│ ├─ observer.OnCommit()
│ ├─ AfterCommit hooks
│ ├─ AfterCompletion hooks
│ └─ observer.OnComplete(committed: true)
│
└─ [exception]
├─ BeforeRollback hooks
├─ scope.Dispose() ← rolled back (no Complete)
├─ observer.OnRollback()
├─ AfterRollback hooks
├─ AfterCompletion hooks
└─ observer.OnComplete(committed: false)
TransactionProxy<T> — routing only
TransactionProxy<T> is a DispatchProxy subclass. Its Invoke() method is responsible for routing and caching only — all commit/rollback logic lives in TransactionScopeExecutor.
Attribute lookup — two-step search:
DispatchProxy.Invoke always receives the interface MethodInfo. The proxy checks the interface method for [Transactional] first. If not found, it resolves the corresponding concrete method via Type.GetInterfaceMap() and checks that. This lets you place the attribute on either the interface or the concrete class.
Cache key: (MethodInfo interfaceMethod, Type concreteType). Including the concrete type correctly handles the case where multiple implementations satisfy the same interface.
Return-type dispatch: After opening the scope, Invoke() branches by return type:
ValueTask/ValueTask<T>— wrapped viaHandleValueTask/HandleValueTaskGenericTask/Task<T>— wrapped viaHandleAsync/HandleAsyncGeneric- Synchronous — executed via
HandleSync
TransactionScope must be created before InvokeTarget
// CORRECT — scope is ambient when InvokeTarget opens a DbConnection
var scope = CreateScope(attr); // ← scope is now Transaction.Current
var task = InvokeTarget(context); // EF Core enlists connection here
// WRONG — scope created after InvokeTarget, connection already opened without it
var task = InvokeTarget(context);
var scope = CreateScope(attr); // too late
If the scope is created after the target begins executing, any DbConnection opened inside the method does not enlist in the ambient transaction, and the rollback on exception becomes a no-op at the database level.
TransactionScopeAsyncFlowOption.Enabled is mandatory
By default, TransactionScope does not flow across await continuations. After an await, Transaction.Current becomes null in the continuation, breaking every subsequent database operation.
TransactionScopeAsyncFlowOption.Enabled fixes this by flowing the ambient transaction through the ExecutionContext — the same mechanism used by AsyncLocal<T>.
Sync-throw-before-task
A Task-returning method can throw synchronously before it returns a Task (e.g., a guard clause before the first await). This path is handled explicitly:
InvokeTargetis wrapped in atry/catch- If it throws synchronously, the exception is captured and converted to a pre-faulted
TaskviaTask.FromException ClearScope()is called to restore theAsyncLocalstate- The pre-faulted task is fed through the normal async rollback wrapper
This ensures BeforeRollback, AfterRollback, AfterCompletion hooks and all observer callbacks fire on this path — no lifecycle steps are skipped.
AsyncLocal hook stack
TransactionHooks uses AsyncLocal<HookCollection?> to maintain per-scope hook registrations that flow correctly across await boundaries.
Each HookCollection carries a Previous pointer forming a linked-list stack. BeginScope reads Transaction.Current to decide the collection's role:
| Role | When | Behaviour |
|---|---|---|
Owning |
New scope (no ambient, or RequiresNew) |
Allocated a new HookCollection; ClearScope restores Previous |
Joining |
Inner Required joining an outer scope |
Shares the outer collection; ClearScope is a no-op |
SuppressThrowaway |
Suppress scope |
Sets the slot to null; ClearScope restores Previous |
ClearScope is called synchronously from the async wrapper before returning the task to the caller, then again from TryDispose. The second call is guarded so it cannot clobber a new scope opened by a concurrent continuation.
TransactionScopeExecutor — all lifecycle logic
TransactionScopeExecutor is a non-generic static class. Keeping it non-generic means the reflection fields used for MakeGenericMethod calls (WrapGenericTaskAsyncMethod, WrapGenericValueTaskAsyncMethod) are computed once per application, not once per proxied interface type.
Rollback decisions are delegated to RollbackPolicy (internal). RollbackPolicy.From(attr) captures the attribute configuration at scope-open time; ShouldRollback(ex) implements a three-rule precedence check:
- If the exception type is in
NoRollbackFor→ commit - If
RollbackForis non-empty and the type is not listed → commit - Otherwise → rollback