-
Notifications
You must be signed in to change notification settings - Fork 16
Execution Context
The Execution Context is a powerful feature in LiteBus that provides access to contextual information throughout a single message processing pipeline. It allows pre-handlers, main handlers, and post-handlers to share data and control the execution flow without modifying the message contracts themselves.
The execution context is an object that holds metadata about the current mediation operation. It is created when a message is sent to a mediator and is disposed of when the operation completes.
LiteBus uses AsyncLocal<T> to manage the context, which keeps it ambient and flowing correctly across async/await boundaries within a single logical thread of execution.
You can access the current execution context statically from anywhere in your code via AmbientExecutionContext.Current.
using LiteBus.Messaging.Abstractions;
public class MyHandler : ICommandHandler<MyCommand>
{
public Task HandleAsync(MyCommand command, CancellationToken cancellationToken = default)
{
// Access the current context
IExecutionContext context = AmbientExecutionContext.Current;
// Use context properties
if (context.Tags.Contains("Admin"))
{
// ...
}
return Task.CompletedTask;
}
}The Items dictionary is a key-value collection (IDictionary<string, object>) for sharing state between handlers in the same pipeline. This is useful for passing data discovered in a pre-handler to downstream handlers.
Example: Passing a User ID from a pre-handler to a post-handler for auditing.
// Pre-handler sets the user ID
public class UserContextPreHandler : ICommandPreHandler<CreateProductCommand>
{
public Task PreHandleAsync(CreateProductCommand command, CancellationToken cancellationToken = default)
{
var userId = GetCurrentUserIdFromHttpContext(); // Your logic here
AmbientExecutionContext.Current.Items["UserId"] = userId;
return Task.CompletedTask;
}
}
// Post-handler uses the user ID for auditing
public class AuditPostHandler : ICommandPostHandler<CreateProductCommand>
{
public Task PostHandleAsync(CreateProductCommand command, object? result, CancellationToken cancellationToken = default)
{
if (AmbientExecutionContext.Current.Items.TryGetValue("UserId", out var userIdObj) && userIdObj is string userId)
{
_auditLogger.Log(userId, "Created a new product.");
}
return Task.CompletedTask;
}
}You can terminate the message pipeline at any point by calling Abort(). This is commonly used in pre-handlers for validation or caching.
When Abort() is called, LiteBus throws a LiteBusExecutionAbortedException internally, which stops the pipeline. No further handlers (main or post) will be executed.
public class PermissionPreHandler : ICommandPreHandler<DeleteProductCommand>
{
public Task PreHandleAsync(DeleteProductCommand command, CancellationToken cancellationToken = default)
{
if (!CurrentUserHasPermission())
{
// Stop processing immediately
AmbientExecutionContext.Current.Abort();
}
return Task.CompletedTask;
}
}If the message expects a result (e.g., IQuery<TResult>), you must provide a result when aborting from a pre-handler. This is a powerful pattern for implementing caching.
public class CachingPreHandler : IQueryPreHandler<GetProductByIdQuery>
{
public Task PreHandleAsync(GetProductByIdQuery query, CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(query.ProductId, out ProductDto cachedProduct))
{
// Abort the pipeline and provide the cached value as the result
AmbientExecutionContext.Current.Abort(cachedProduct);
}
return Task.CompletedTask;
}
}The Tags collection contains the tags that were specified when the message was mediated. This allows handlers to dynamically change their behavior based on the context.
public class ProductQueryHandler : IQueryHandler<GetProductQuery, ProductDto>
{
public Task<ProductDto> HandleAsync(GetProductQuery query, CancellationToken cancellationToken = default)
{
var tags = AmbientExecutionContext.Current.Tags;
if (tags.Contains("IncludeExtraDetails"))
{
// Fetch and return a more detailed DTO
}
else
{
// Return a standard DTO
}
}
}The CancellationToken for the operation is also available on the execution context, which is the same token passed to the handler methods.
The MessageResult property (object? MessageResult { get; set; }) on IExecutionContext serves two distinct purposes:
When you call executionContext.Abort(result) from a pre-handler, LiteBus stores the supplied value in MessageResult and then terminates the pipeline. This is how the mediator knows what value to return when execution is aborted before the main handler runs.
public class CachingPreHandler : IQueryPreHandler<GetProductByIdQuery>
{
public Task PreHandleAsync(GetProductByIdQuery query, CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(query.ProductId, out ProductDto cachedProduct))
{
// Abort writes cachedProduct to MessageResult, then throws internally.
AmbientExecutionContext.Current.Abort(cachedProduct);
}
return Task.CompletedTask;
}
}Post-handler methods return Task, which provides no path to return a new result value to the caller. To work around this, a post-handler can write a replacement result directly to MessageResult. After all post-handlers in the chain have executed, the mediator reads this property and, if it is non-null, returns it to the caller in place of the main handler's original result.
public class EnrichResultPostHandler : ICommandPostHandler<MyCommand, Result<MyResponse>>
{
public async Task PostHandleAsync(
MyCommand message,
Result<MyResponse>? messageResult,
CancellationToken cancellationToken = default)
{
if (messageResult is { IsSuccess: true })
{
// Replace the result with an enriched version.
AmbientExecutionContext.Current.MessageResult =
messageResult.WithMetadata("enriched", true);
}
}
}Important nuances:
- Writing to
MessageResultfrom a pre-handler or the main handler has no effect on the value returned to the caller; only the post-handler code path reads it on the normal (non-aborted) flow.- Last write wins: if multiple post-handlers write to
MessageResult, the value present after the final post-handler executes is the one returned.- This feature applies to commands with results (
ICommand<TResult>) and queries (IQuery<TResult>). It does not apply to void commands (ICommand) or events; writing toMessageResultin those pipelines is silently ignored.
For a complete worked example, see Overriding the Result from a Post-Handler in the Cookbook.
-
Use String Constants for Keys: To avoid typos, define the keys for the
Itemsdictionary asconst stringin a shared class. -
Scope: Remember that the execution context is scoped to a single mediation call. It is not shared across different
SendAsyncorPublishAsynccalls. - Avoid Overuse: The context is for cross-cutting concerns. Core business data should always be part of the message contract itself.