Message Context
The message context carries metadata through the pipeline, enabling correlation, multi-tenancy, and custom data propagation.
Without message context, every handler needs to manually thread correlation IDs, tenant info, and user identity through method parameters. Message context solves this by providing a per-request ambient bag that all middleware and handlers can read and write -- similar to HttpContext in ASP.NET Core but for any dispatch pipeline (HTTP, queue, background job).
Before You Start
- .NET 8.0+ (or .NET 9/10 for latest features)
- Install the required package:
dotnet add package Excalibur.Dispatch - Familiarity with actions and handlers and pipeline concepts
What is Message Context?
Every message dispatch has an associated IMessageContext that flows through the pipeline. It contains:
- MessageId - Unique identifier for this message
- CorrelationId - Unique identifier linking related operations
- CausationId - ID of the operation that caused this one
- Items - Key-value store for transport-specific and custom data
- Features - Typed feature collection for cross-cutting concerns (identity, routing, processing state, etc.)
Accessing Context
In Handlers
Use IMessageContextAccessor to access the current context:
using Excalibur.Dispatch.Abstractions;
using Excalibur.Dispatch.Abstractions.Features;
public class CreateOrderHandler : IActionHandler<CreateOrderAction>
{
private readonly IMessageContextAccessor _contextAccessor;
public CreateOrderHandler(IMessageContextAccessor contextAccessor)
{
_contextAccessor = contextAccessor;
}
public async Task HandleAsync(CreateOrderAction action, CancellationToken ct)
{
var context = _contextAccessor.MessageContext;
// Core property (direct on interface)
var correlationId = context.CorrelationId;
// Cross-cutting concerns via feature extensions
var tenantId = context.GetTenantId();
var userId = context.GetUserId();
// Use in logging
_logger.LogInformation(
"Processing order for tenant {TenantId} with correlation {CorrelationId}",
tenantId,
correlationId);
}
}
In Middleware
Middleware receives context directly:
public class LoggingMiddleware : IDispatchMiddleware
{
private readonly ILogger<LoggingMiddleware> _logger;
public LoggingMiddleware(ILogger<LoggingMiddleware> logger)
{
_logger = logger;
}
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.Processing;
public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate nextDelegate,
CancellationToken cancellationToken)
{
var correlationId = context.CorrelationId;
_logger.LogInformation(
"[{CorrelationId}] Handling {MessageType}",
correlationId,
message.GetType().Name);
return await nextDelegate(message, context, cancellationToken);
}
}
Core Properties vs Features
IMessageContext has 8 core properties plus a typed Features dictionary for cross-cutting concerns:
Core Properties (Direct on Interface)
| Property | Type | Purpose |
|---|---|---|
MessageId | string? | Unique message identifier |
CorrelationId | string? | Links related operations |
CausationId | string? | Links to causing message |
Message | IDispatchMessage? | The message payload |
Result | object? | Handler result |
RequestServices | IServiceProvider | Scoped DI container |
Items | IDictionary<string, object> | Transport metadata and custom data |
Features | IDictionary<Type, object> | Typed feature collection |
Feature Interfaces
Cross-cutting concerns are accessed via typed feature interfaces:
| Feature | Properties | Use Case |
|---|---|---|
IMessageIdentityFeature | UserId, TenantId, SessionId, WorkflowId, ExternalId, TraceParent | Identity and multi-tenancy |
IMessageProcessingFeature | ProcessingAttempts, IsRetry, FirstAttemptTime, DeliveryCount | Retry and delivery tracking |
IMessageValidationFeature | ValidationPassed, ValidationTimestamp | Validation state |
IMessageTimeoutFeature | TimeoutExceeded, TimeoutElapsed | Timeout tracking |
IMessageRateLimitFeature | RateLimitExceeded, RateLimitRetryAfter | Rate limiting |
IMessageRoutingFeature | RoutingDecision, PartitionKey, Source | Routing decisions |
IMessageTransactionFeature | Transaction, TransactionId | Transaction context |
using Excalibur.Dispatch.Abstractions.Features;
// Read via convenience extensions
var tenantId = context.GetTenantId();
var isRetry = context.GetIsRetry();
var partitionKey = context.GetPartitionKey();
// Write via feature instance
var identity = context.GetOrCreateIdentityFeature();
identity.TenantId = "acme-corp";
identity.UserId = currentUser.Id;
// Processing state
var processing = context.GetOrCreateProcessingFeature();
processing.ProcessingAttempts++;
Context Items
Use context items to pass custom data through the pipeline.
Setting Items
Items are typically set early in the pipeline (middleware):
public class TenantMiddleware : IDispatchMiddleware
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantMiddleware(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.PreProcessing;
public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate nextDelegate,
CancellationToken cancellationToken)
{
// Extract tenant from HTTP header and set on identity feature
var tenantId = _httpContextAccessor.HttpContext?.Request
.Headers["X-Tenant-Id"]
.FirstOrDefault();
if (!string.IsNullOrEmpty(tenantId))
{
var identity = context.GetOrCreateIdentityFeature();
identity.TenantId = tenantId;
}
return await nextDelegate(message, context, cancellationToken);
}
}
Reading Items
// Get with type (for custom/transport-specific data)
var customValue = context.GetItem<string>("MyCustomKey");
// Check existence and get value
if (context.ContainsItem("MyCustomKey"))
{
var value = context.GetItem<string>("MyCustomKey");
// Use value
}
// Get all items
var items = context.Items;
Type-Safe Context Extensions
Define extension methods for type-safe access to custom data:
public static class OrderContextExtensions
{
private const string OrderIdKey = "Order.OrderId";
public static string? GetOrderId(this IMessageContext context)
=> context.GetItem<string>(OrderIdKey);
public static void SetOrderId(this IMessageContext context, string orderId)
=> context.SetItem(OrderIdKey, orderId);
}
// Usage
context.SetOrderId("ORD-12345");
var orderId = context.GetOrderId();
In addition to the identity feature, ITenantId is available as a scoped DI service registered via TryAddTenantId(). When no tenant is explicitly configured, the framework uses TenantDefaults.DefaultTenantId ("Default") automatically — single-tenant applications work without any tenant setup.
// Single-tenant: ITenantId.Value is "Default" automatically
services.AddExcaliburA3()
.UsePostgres();
// Multi-tenant: resolve per-request before A3
services.TryAddTenantId(sp =>
{
var httpContext = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
return httpContext?.Request.Headers["X-Tenant-ID"].FirstOrDefault()
?? TenantDefaults.DefaultTenantId;
});
services.AddExcaliburA3()
.UsePostgres();
The TenantIdentityMiddleware in the pipeline sets the context-level tenant from HTTP headers, message properties, or a configured default. The DI-registered ITenantId is used by A3 authorization, grants, and audit logging.
Correlation ID
The correlation ID links all related operations together.
Automatic Generation
When dispatching without an existing context, a new CorrelationId is generated:
// Top-level dispatch - new CorrelationId generated
await dispatcher.DispatchAsync(action, cancellationToken);
Preserving Correlation
When dispatching from within a handler, use DispatchChildAsync to preserve correlation:
public class OrderHandler : IActionHandler<CreateOrderAction>
{
public async Task HandleAsync(CreateOrderAction action, CancellationToken ct)
{
// Child dispatch - same CorrelationId
await _dispatcher.DispatchChildAsync(new NotifyAction(), ct);
}
}
External Correlation
Propagate correlation from HTTP headers:
public class CorrelationMiddleware
{
public async Task InvokeAsync(
HttpContext httpContext,
IMessageContextAccessor contextAccessor)
{
var correlationId = httpContext.Request.Headers["X-Correlation-Id"]
.FirstOrDefault()
?? Guid.NewGuid().ToString();
// Set correlation ID on current context
var context = contextAccessor.MessageContext;
context.CorrelationId = correlationId;
// Add to response headers
httpContext.Response.Headers["X-Correlation-Id"] = correlationId;
await _next(httpContext);
}
}
Causation Chain
Track what caused what with causation IDs:
// Original action
// CorrelationId: abc123
// CausationId: (none - this is the root)
// Child action dispatched from handler
// CorrelationId: abc123 (same)
// CausationId: original-message-id
This creates an audit trail:
- All related actions share the same
CorrelationId - Each action knows what caused it via
CausationId
Child Context Creation
Create child contexts for cascading messages:
var childContext = context.CreateChildContext();
Propagated automatically:
CorrelationId- Maintains distributed tracingIMessageIdentityFeature(TenantId, UserId, SessionId, WorkflowId, ExternalId, TraceParent)IMessageRoutingFeature.Source- Preserves origin tracking
Set automatically:
CausationId- Set to parent'sMessageIdMessageId- New unique identifier
Context in Distributed Systems
When messages cross service boundaries:
Outgoing Messages
public class OutboxMiddleware : IDispatchMiddleware
{
private readonly IOutboxStore _outboxStore;
public OutboxMiddleware(IOutboxStore outboxStore)
{
_outboxStore = outboxStore;
}
public DispatchMiddlewareStage? Stage => DispatchMiddlewareStage.PostProcessing;
public async ValueTask<IMessageResult> InvokeAsync(
IDispatchMessage message,
IMessageContext context,
DispatchRequestDelegate nextDelegate,
CancellationToken cancellationToken)
{
var result = await nextDelegate(message, context, cancellationToken);
// Include context in outbox message
var outboxMessage = new OutboxMessage
{
CorrelationId = context.CorrelationId,
Metadata = context.Items
};
await _outboxStore.SaveAsync(outboxMessage, cancellationToken);
return result;
}
}
Incoming Messages
public class MessageConsumer
{
public async Task HandleMessageAsync(TransportMessage message)
{
// Restore context from incoming message
var context = new MessageContext
{
CorrelationId = message.CorrelationId,
CausationId = message.MessageId
};
foreach (var item in message.Metadata)
{
context.SetItem(item.Key, item.Value);
}
// Dispatch with restored context
await _dispatcher.DispatchAsync(action, context, cancellationToken);
}
}
Best Practices
Do
- Use feature extensions for cross-cutting concerns (
GetTenantId(),GetUserId()) - Use type-safe extension methods for custom Items data
- Propagate context in child dispatches
- Include correlation in all logging
- Preserve context across service boundaries
Don't
- Store large objects in context items
- Use context for data that should be in the action
- Assume Items exist without checking
- Modify context in handlers (do it in middleware)
- Use Items for data that has a feature interface (identity, routing, processing state)
What's Next
- Results and Errors - Handle success and failure
- Configuration - Configure Dispatch options
- Pipeline - Add behaviors that interact with context
- Middleware - Cross-cutting concerns using context
See Also
- Actions and Handlers — Define the messages that flow through the context pipeline
- MessageContext Design — Architectural decisions behind the MessageContext design
- MessageContext Items Usage — Patterns and conventions for using context items across the framework
- Custom Middleware — Build middleware that reads and writes context items