Skip to content

Messaging

In a modular monolith, modules need to communicate without coupling. A patient discharge in module A should release a bed in module B — but module A must not reference module B. The obvious answer is events. The less obvious problem: background processing needs the same tenant, user, and trace context as the originating HTTP request, or your audit trails break, your multi-tenant queries return wrong data, and your distributed traces have gaps.

Granit solves both problems through two event types, a transactional outbox, and automatic context propagation.

Domain events stay within the same process. They are routed to a local queue named "domain-events" and never leave the application boundary. Use them for side effects that belong to the same bounded context.

public sealed record PatientDischargedOccurred(
Guid PatientId, Guid BedId) : IDomainEvent;

Characteristics:

  • Same process, same transaction boundary
  • Routed to the local "domain-events" queue
  • Never serialized to an external transport
  • Retried according to the configured retry policy

IIntegrationEvent — durable, cross-module

Section titled “IIntegrationEvent — durable, cross-module”

Integration events cross module boundaries. They are persisted in the transactional outbox and survive process crashes. Use them when the consumer is a different module or when at-least-once delivery matters.

public sealed record BedReleasedEvent(
Guid BedId, Guid WardId, DateTimeOffset ReleasedAt) : IIntegrationEvent;

Characteristics:

  • Persisted in the outbox alongside business data
  • Delivered at-least-once (survives crashes, restarts)
  • Serialized as flat DTOs — never include EF Core entities or internal domain objects
  • Transported via PostgreSQL-based transport

The outbox pattern eliminates dual-write problems. Events are persisted in the same database transaction as business data. If the transaction commits, events will be delivered. If it rolls back, events are discarded. No orphaned messages, no lost updates.

sequenceDiagram
    participant H as Handler
    participant DB as PostgreSQL
    participant O as Outbox Table
    participant T as Transport

    H->>DB: UPDATE patients SET status = 'discharged'
    H->>O: INSERT outbox message (same TX)
    H->>DB: COMMIT
    Note over DB,O: Both succeed or both rollback
    O->>T: Dispatcher reads committed messages
    T-->>O: ACK -- DELETE from outbox

The handler does not need to manage any of this explicitly. Returning an event from a handler is enough:

public static class DischargePatientHandler
{
public static IEnumerable<object> Handle(
DischargePatientCommand command,
PatientDbContext db)
{
var patient = db.Patients.Find(command.PatientId)
?? throw new EntityNotFoundException(typeof(Patient), command.PatientId);
patient.Discharge();
// Domain event -- same transaction, local queue
yield return new PatientDischargedOccurred(patient.Id, patient.BedId);
// Integration event -- persisted in outbox, durable delivery
yield return new BedReleasedEvent(
patient.BedId, patient.WardId, DateTimeOffset.UtcNow);
}
}

Three contexts propagate automatically through message envelopes, so background handlers behave exactly like the original HTTP request.

OutgoingContextMiddleware reads the current tenant, user, and trace context from the ambient scope and writes them as headers on the outgoing message envelope:

HeaderSourcePurpose
X-Tenant-IdICurrentTenant.IdTenant isolation in background handlers
X-User-IdICurrentUserService.UserIdAudit trail (CreatedBy, ModifiedBy)
X-User-FirstNameICurrentUserService.FirstNameAudit display name
X-User-LastNameICurrentUserService.LastNameAudit display name
X-Actor-KindICurrentUserService.ActorKindUser, ExternalSystem, or System
X-Api-Key-IdICurrentUserService.ApiKeyIdService account identification
traceparentActivity.Current?.IdW3C Trace Context for distributed tracing

Headers are omitted when the corresponding context is absent. No PII is logged.

Three behaviors restore the context before the handler executes:

BehaviorRestoresMechanism
TenantContextBehaviorICurrentTenantSets AsyncLocal tenant scope
UserContextBehaviorICurrentUserServiceSets AsyncLocal user override via WolverineCurrentUserService
TraceContextBehaviorActivity (trace/span)Creates a new Activity linked to the parent traceparent
sequenceDiagram
    participant HTTP as HTTP Request
    participant Mid as OutgoingContextMiddleware
    participant Env as Message Envelope
    participant Beh as Incoming Behaviors
    participant Handler as Async Handler

    HTTP->>Mid: TenantId, UserId, TraceId from ambient context
    Mid->>Env: X-Tenant-Id, X-User-Id, traceparent headers
    Note over Env: Persisted in outbox (same TX as business data)
    Env->>Beh: Read headers on dispatch
    Beh->>Handler: ICurrentTenant, ICurrentUserService, Activity restored
    Note over Handler: AuditedEntityInterceptor sees correct CreatedBy/ModifiedBy

The result: Grafana/Tempo shows a continuous trace from the original HTTP request through every async handler it triggered. Audit fields are populated correctly. Multi-tenant query filters apply the right tenant.

In an HTTP request, ICurrentUserService reads claims from HttpContext.User. In a background handler, there is no HttpContext. WolverineCurrentUserService bridges this gap with a two-level resolution strategy:

  1. AsyncLocal override — set by UserContextBehavior from envelope headers. Used in background handlers.
  2. HttpContext fallback — reads claims from IHttpContextAccessor. Used in normal HTTP requests.

This is registered automatically by AddGranitWolverine(). You never interact with it directly — ICurrentUserService just works in both contexts.

RecurringJobSchedulingMiddleware inserts the next cron schedule in the same outbox transaction as the current job’s completion. If the handler fails and the transaction rolls back, the reschedule is also rolled back — preventing duplicate schedules or missed runs.

{
"Wolverine": {
"MaxRetryAttempts": 3,
"RetryDelays": ["00:00:05", "00:00:30", "00:05:00"]
},
"WolverinePostgresql": {
"TransportConnectionString": "Host=db;Database=myapp;Username=app;Password=..."
}
}
PropertyDefaultDescription
MaxRetryAttempts3Maximum delivery attempts before dead-letter
RetryDelays[5s, 30s, 5min]Delay between retries (exponential backoff)
TransportConnectionStringPostgreSQL connection for the outbox transport
[DependsOn(typeof(GranitWolverinePostgresqlModule))]
public class AppModule : GranitModule { }

Production setup. Events are persisted in the outbox and delivered durably.