Introduction
In our previous exploration of execution context in .NET Core, we delved into the intricacies of AsyncLocal
and ThreadLocal
.
While these primitives provide powerful building blocks for managing state across asynchronous boundaries, the Ambient Context pattern builds upon them to offer a more structured and maintainable approach for enterprise applications.
Core Implementation
The foundation of the pattern leverages AsyncLocal<T>
to maintain context across asynchronous boundaries while adding crucial features for production scenarios:
public class AmbientContext<T> : IDisposable where T : class
{
private class ScopeInstance
{
public T? Item { get; init; }
public ScopeInstance? Parent { get; init; }
public DateTime Created { get; } = DateTime.UtcNow;
public string CreatedBy { get; init; } =
Thread.CurrentThread.ManagedThreadId.ToString();
}
private static readonly AsyncLocal<ScopeInstance?> _current = new();
private readonly ScopeInstance? _previousScope;
private bool _disposed;
public T? Item { get; }
protected AmbientContext(T? item)
{
Item = item;
_previousScope = _current.Value;
_current.Value = new ScopeInstance
{
Item = item,
Parent = _previousScope
};
}
public static AmbientContext<T>? Current => _current.Value != null
? new AmbientContext<T>(_current.Value.Item)
: null;
public static IEnumerable<T> Stack
{
get
{
var current = _current.Value;
while (current != null)
{
if (current.Item != null)
yield return current.Item;
current = current.Parent;
}
}
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
if (_current.Value?.Parent != _previousScope)
throw new InvalidOperationException(
"Ambient context stack corruption detected");
_current.Value = _previousScope;
}
_disposed = true;
}
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}
This enhanced implementation adds:
- Stack corruption detection
- Diagnostic information for debugging
- Stack traversal capabilities
- Type constraints ensuring reference types
Real-World Applications
Distributed Tracing with Correlation IDs
Let's implement a complete distributed tracing solution:
public sealed class CorrelationContext : AmbientContext<CorrelationInfo>
{
public CorrelationContext(string correlationId, string? parentId = null)
: base(new CorrelationInfo(correlationId, parentId)) { }
public static string CurrentCorrelationId =>
Current?.Item?.Id ??
throw new InvalidOperationException("No correlation context found");
public static IEnumerable<string> TraceChain =>
Stack.Select(info => info.Id);
}
public record CorrelationInfo(string Id, string? ParentId);
// Usage in a service layer
public class OrderService
{
private readonly ILogger<OrderService> _logger;
private readonly IMessageBus _messageBus;
public async Task ProcessOrderAsync(Order order)
{
// Nested context for sub-operation
using var orderContext = new CorrelationContext(
$"order-{order.Id}",
CorrelationContext.CurrentCorrelationId);
_logger.LogInformation(
"Processing order {OrderId} in trace {TraceId}",
order.Id,
string.Join(" -> ", CorrelationContext.TraceChain));
await _messageBus.PublishAsync(new OrderProcessedEvent
{
OrderId = order.Id,
CorrelationId = CorrelationContext.CurrentCorrelationId,
TraceChain = CorrelationContext.TraceChain.ToList()
});
}
}
Multi-tenant Context with Validation
A robust multi-tenant implementation with validation and auditing:
public record TenantContext(
string TenantId,
string UserId,
IReadOnlySet<string> Permissions,
string Environment)
{
public bool HasPermission(string permission) =>
Permissions.Contains(permission);
public void ValidateEnvironment(string expected)
{
if (Environment != expected)
throw new InvalidOperationException(
$"Invalid environment. Expected {expected}, got {Environment}");
}
}
public class ApplicationContext : AmbientContext<TenantContext>
{
private ApplicationContext(TenantContext context) : base(context) { }
public static ApplicationContext CreateScope(TenantContext context)
{
if (string.IsNullOrEmpty(context.TenantId))
throw new ArgumentException("TenantId cannot be empty");
if (string.IsNullOrEmpty(context.UserId))
throw new ArgumentException("UserId cannot be empty");
return new ApplicationContext(context);
}
public static void RequirePermission(string permission)
{
var context = Current?.Item ??
throw new InvalidOperationException("No application context");
if (!context.HasPermission(permission))
throw new UnauthorizedAccessException(
$"Missing required permission: {permission}");
}
}
// Usage in a repository
public class TenantAwareRepository<T> where T : class
{
private readonly DbContext _db;
public async Task<T?> FindByIdAsync(object id)
{
var context = ApplicationContext.Current?.Item ??
throw new InvalidOperationException("No tenant context");
// Validate environment
context.ValidateEnvironment("Production");
// Require read permission
ApplicationContext.RequirePermission($"{typeof(T).Name}.Read");
return await _db.Set<T>()
.FirstOrDefaultAsync(e =>
EF.Property<string>(e, "TenantId") == context.TenantId &&
EF.Property<object>(e, "Id") == id);
}
}
Advanced Scenarios
Handling Parallel Processing
Proper context management in parallel scenarios:
public class BatchProcessor
{
public async Task ProcessItemsAsync(IEnumerable<string> items)
{
// Capture the parent context
var parentContext = ApplicationContext.Current?.Item;
// Process items in parallel while maintaining context hierarchy
await Task.WhenAll(items.Select(async item =>
{
// Create child context for each parallel task
using var itemContext = ApplicationContext.CreateScope(
parentContext with
{
UserId = $"{parentContext.UserId}:batch",
Permissions = parentContext.Permissions
});
await ProcessSingleItemAsync(item);
}));
}
}
Context Factory Pattern
A factory pattern for managing complex context creation:
public interface IContextFactory<T> where T : class
{
AmbientContext<T> CreateScope(T context);
AmbientContext<T> CreateChildScope(T context);
}
public class ApplicationContextFactory : IContextFactory<TenantContext>
{
private readonly ILogger<ApplicationContextFactory> _logger;
private readonly IValidationService _validation;
public AmbientContext<TenantContext> CreateScope(TenantContext context)
{
_validation.ValidateContext(context);
_logger.LogInformation(
"Creating new context for tenant {TenantId}",
context.TenantId);
return ApplicationContext.CreateScope(context);
}
public AmbientContext<TenantContext> CreateChildScope(
TenantContext context)
{
var parent = ApplicationContext.Current?.Item;
if (parent == null)
throw new InvalidOperationException(
"Cannot create child scope without parent");
if (parent.TenantId != context.TenantId)
throw new InvalidOperationException(
"Child context must belong to same tenant");
return ApplicationContext.CreateScope(context);
}
}
Integration Patterns
ASP.NET Core Integration with Error Handling
Enhanced middleware implementation:
public class AmbientContextMiddleware<T> where T : class
{
private readonly RequestDelegate _next;
private readonly IContextFactory<T> _factory;
private readonly ILogger<AmbientContextMiddleware<T>> _logger;
public async Task InvokeAsync(HttpContext httpContext)
{
try
{
var context = await BuildContextAsync(httpContext);
using var ambient = _factory.CreateScope(context);
await _next(httpContext);
}
catch (Exception ex) when (ex is UnauthorizedAccessException
or InvalidOperationException)
{
_logger.LogWarning(ex, "Context validation failed");
httpContext.Response.StatusCode =
StatusCodes.Status403Forbidden;
}
}
private async Task<T> BuildContextAsync(HttpContext context)
{
// Implementation specific to T
throw new NotImplementedException();
}
}
Background Job Integration
Robust background job processing:
public class BackgroundJobProcessor : BackgroundService
{
private readonly IContextFactory<TenantContext> _contextFactory;
private readonly IJobQueue _jobQueue;
private readonly ILogger<BackgroundJobProcessor> _logger;
protected override async Task ExecuteAsync(
CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
var job = await _jobQueue.DequeueAsync(stoppingToken);
if (job == null) continue;
using var jobScope = _contextFactory.CreateScope(
new TenantContext(
job.TenantId,
"system",
new HashSet<string> { "system.job.process" },
job.Environment
));
await ProcessJobWithRetryAsync(job, stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Job processing failed");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
private async Task ProcessJobWithRetryAsync(
Job job,
CancellationToken ct)
{
var retryPolicy = Policy
.Handle<Exception>()
.WaitAndRetryAsync(3, attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt)));
await retryPolicy.ExecuteAsync(async () =>
{
using var processingScope = _contextFactory.CreateChildScope(
ApplicationContext.Current!.Item with
{
UserId = $"system:retry:{job.Id}"
});
await ProcessJobAsync(job, ct);
});
}
}
Best Practices and Guidelines
Choose Ambient Context when:
- Tracing and correlation needs span multiple service boundaries
- Multi-tenant operations require consistent context
- Cross-cutting concerns need clean integration points
- Method signatures would become unwieldy with explicit context
- Audit trails need automatic context capturing
Avoid when:
- Business logic requires explicit context validation
- Testing scenarios demand high context visibility
- Parallel processing forms the core operation model
- Performance is absolutely critical (context switching adds overhead)
- Simple dependency injection would suffice
Implementation Tips
- Always implement proper disposal patterns
- Add diagnostic information for debugging
- Consider validation at context creation
- Implement stack corruption detection
- Provide clear factory methods for context creation
- Add proper error handling and logging
- Consider performance implications in high-throughput scenarios
- implement proper async handling
Conclusion
The Ambient Context pattern provides a robust solution for managing contextual state in modern .NET applications. By building upon AsyncLocal<T>
with proper scope management, validation, and diagnostic capabilities, it offers a maintainable approach to handling cross-cutting concerns. Understanding its implementation details, benefits, and limitations helps make informed decisions about when to apply this pattern in your architecture.
Have a goat day 🐐
Join the conversation.