Skip to main content

Message Context

The message context carries metadata through the pipeline, enabling correlation, multi-tenancy, and custom data propagation.

Why Should I Care?

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​

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;
using Excalibur.Dispatch.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)​

PropertyTypePurpose
MessageIdstring?Unique message identifier
CorrelationIdstring?Links related operations
CausationIdstring?Links to causing message
MessageIDispatchMessage?The message payload
Resultobject?Handler result
RequestServicesIServiceProviderScoped DI container
ItemsIDictionary<string, object>Transport metadata and custom data
FeaturesIDictionary<Type, object>Typed feature collection

Feature Interfaces​

Cross-cutting concerns are accessed via typed feature interfaces:

FeaturePropertiesUse Case
IMessageIdentityFeatureUserId, TenantId, SessionId, WorkflowId, ExternalId, TraceParentIdentity and multi-tenancy
IMessageProcessingFeatureProcessingAttempts, IsRetry, FirstAttemptTime, DeliveryCountRetry and delivery tracking
IMessageValidationFeatureValidationPassed, ValidationTimestampValidation state
IMessageTimeoutFeatureTimeoutExceeded, TimeoutElapsedTimeout tracking
IMessageRateLimitFeatureRateLimitExceeded, RateLimitRetryAfterRate limiting
IMessageRoutingFeatureRoutingDecision, PartitionKey, SourceRouting decisions
IMessageTransactionFeatureTransaction, TransactionIdTransaction context
using Excalibur.Dispatch.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();
Ambient Tenant Context

The current tenant is exposed as the ambient ITenantContext (registered via AddTenantContext(), which AddExcaliburA3() calls for you). That context reports whichever tenant the current execution flow has established through TenantContextHolder.BeginScope — it does not select a tenant of its own, and no framework component substitutes one when the scope is empty. On Excalibur.Hosting.Web, the per-request middleware opens that scope from the request's X-Tenant-Id header or tenantId query value (both must parse as a Guid); when a request supplies neither, the scope is opened with no tenant and ITenantContext.HasTenant reports false. A single-tenant deployment that wants every operation to run under one identity opens the scope itself, using the framework's single-tenant identifier TenantDefaults.DefaultTenantId. Read that identifier from the constant rather than copying its literal value — it is the identity your rows are stored under, so a hardcoded copy that drifts from it silently changes which rows an operation can see.

services.AddExcaliburA3()
.UsePostgres();

// Multi-tenant: establish the ambient tenant per request before dispatching.
// Excalibur.Hosting.Web wires this middleware automatically; to scope manually:
var tenantId = httpContext?.Request.Headers["X-Tenant-ID"].FirstOrDefault();
using (TenantContextHolder.BeginScope(tenantId))
{
// ITenantContext.TenantId resolves to this tenant here.
}

Web apps built on Excalibur.Hosting.Web establish the ambient tenant per request automatically (from HTTP headers / message properties, falling back to the configured default). The ambient ITenantContext is used by A3 authorization, grants, and audit search — which fail closed when no tenant is resolved. Audit retrieval by event ID is not tenant-scoped; see 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, call DispatchAsync — it automatically preserves correlation by dispatching a child message:

public class OrderHandler : IActionHandler<CreateOrderAction>
{
public async Task HandleAsync(CreateOrderAction action, CancellationToken ct)
{
// Child dispatch - same CorrelationId
await _dispatcher.DispatchAsync(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 tracing
  • IMessageIdentityFeature (TenantId, UserId, SessionId, WorkflowId, ExternalId, TraceParent)
  • IMessageRoutingFeature.Source - Preserves origin tracking

Set automatically:

  • CausationId - Set to parent's MessageId
  • MessageId - New unique identifier

Context in Distributed Systems​

When messages cross service boundaries:

Outgoing Messages​

public class OutboxMiddleware : IDispatchMiddleware
{
private readonly IOutboxWriter _outboxWriter;

public OutboxMiddleware(IOutboxWriter outboxWriter)
{
_outboxWriter = outboxWriter;
}

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);

// The writer captures the ambient context — correlation, causation and the
// context items — onto the staged message; nothing to copy by hand.
await _outboxWriter.WriteAsync(message, destination: "orders", 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​

See Also​