Skip to content

Sidecar / Wolverine Behavior

The Sidecar pattern attaches cross-cutting responsibilities to a main component without modifying its code. In the Wolverine context, Behaviors (Before/After) execute around each message handler, restoring the context (tenant, user, trace) lost during the transition from HTTP > Outbox > background thread.

Granit implements three incoming behaviors and one outgoing middleware, forming a context bridge between HTTP requests and asynchronous processing.

sequenceDiagram
    participant HTTP as HTTP Request
    participant OCM as OutgoingContextMiddleware
    participant ENV as Envelope (headers)
    participant TCB as TenantContextBehavior
    participant UCB as UserContextBehavior
    participant TrCB as TraceContextBehavior
    participant H as Handler

    Note over HTTP,ENV: Outgoing -- header injection
    HTTP->>OCM: Outgoing message
    OCM->>ENV: X-Tenant-Id = {tenantId}
    OCM->>ENV: X-User-Id = {userId}
    OCM->>ENV: traceparent = {traceId}

    Note over ENV,H: Incoming -- context restoration
    ENV->>TCB: Before()
    TCB->>TCB: ICurrentTenant.Change(tenantId)
    TCB->>UCB: Before()
    UCB->>UCB: IWolverineUserContextSetter.Change(userId)
    UCB->>TrCB: Before()
    TrCB->>TrCB: Activity.SetParentId(traceparent)
    TrCB->>H: HandleAsync()
    H-->>TrCB: Result
    TrCB-->>UCB: After() -- restore
    UCB-->>TCB: After() -- restore
ComponentFileRole
OutgoingContextMiddlewaresrc/Granit.Wolverine/Middleware/OutgoingContextMiddleware.csInjects X-Tenant-Id, X-User-Id, traceparent into outgoing Wolverine envelopes

Injection conditions:

  • X-Tenant-Id: only if currentTenant.Id.HasValue
  • X-User-Id: only if currentUserService.IsAuthenticated and UserId is { Length: > 0 }
  • traceparent: if an Activity is in progress
BehaviorFileLifecycleRole
TenantContextBehaviorsrc/Granit.Wolverine/Behaviors/TenantContextBehavior.csBefore/AfterReads X-Tenant-Id > ICurrentTenant.Change(tenantId)
UserContextBehaviorsrc/Granit.Wolverine/Behaviors/UserContextBehavior.csBefore/AfterReads X-User-Id > IWolverineUserContextSetter.Change(userId)
TraceContextBehaviorsrc/Granit.Wolverine/Behaviors/TraceContextBehavior.csBefore/AfterReads traceparent > links the handler’s Activity to the trace parent

Each Before() returns an IDisposable scope. The After() disposes the scope, restoring the previous context (important for chained handlers).

In src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.cs:

opts.Policies.AddMiddleware<OutgoingContextMiddleware>();
// Behaviors are registered via Wolverine's handler chain discovery
InterfaceFileRole
IWolverineUserContextSettersrc/Granit.Wolverine/Internal/IWolverineUserContextSetter.csAllows replacing the user context without HttpContext
WolverineCurrentUserServicesrc/Granit.Wolverine/Internal/WolverineCurrentUserService.csAsyncLocal implementation: override > HttpContext fallback
ProblemSolution
Background handlers have no HttpContextBehaviors restore context from envelope headers
EF Core audit interceptor needs ModifiedBy in backgroundUserContextBehavior restores ICurrentUserService via AsyncLocal
OpenTelemetry traces are disjointed between HTTP and asyncTraceContextBehavior links spans via W3C traceparent
Multi-tenant EF Core query filters do not work in backgroundTenantContextBehavior restores ICurrentTenant so filters apply
The handler must remain pure (no infrastructure code)All context machinery is in the behaviors, invisible to the handler
// The handler is completely pure -- no awareness of behaviors
public static class ProcessMedicalReportHandler
{
public static async Task Handle(
ProcessMedicalReportCommand command,
ICurrentTenant currentTenant, // restored by TenantContextBehavior
ICurrentUserService currentUser, // restored by UserContextBehavior
AppDbContext db,
CancellationToken cancellationToken)
{
// currentTenant.Id is the same as the originating HTTP request
// currentUser.UserId is the same as the user who initiated the operation
MedicalReport report = await db.Reports.FindAsync([command.ReportId], ct)
?? throw new EntityNotFoundException(typeof(MedicalReport), command.ReportId);
report.MarkAsProcessed();
await db.SaveChangesAsync(ct);
// AuditedEntityInterceptor records:
// ModifiedBy = currentUser.UserId (not "system")
// The OpenTelemetry Activity is linked to the HTTP trace parent
}
}