Envelope Registry¶
Verified by tests
EnvelopeRegistryTests — library CI run #31657041675 (2026-08-13)
The IEnvelopeRegistry provides a way to look up message envelopes by their payload reference. This enables APIs that accept raw messages to access the full envelope context.
Overview¶
When processing messages, sometimes only the message payload is available, but the envelope context (correlation ID, hops, security context) is needed. The envelope registry bridges this gap:
- Register envelopes when created by the dispatcher
- Lookup envelopes by message payload reference
- Unregister when processing completes
IEnvelopeRegistry Interface¶
IEnvelopeRegistry Interface
namespace Whizbang.Core.Observability;
/// <summary>
/// Registry for tracking message envelopes by their message payload.
/// Enables looking up the envelope for a message when only the message is available.
/// </summary>
/// <remarks>
/// The registry uses object reference identity (not equality) to look up messages.
/// This means the exact same message instance must be used for registration and lookup.
///
/// Typical flow:
/// 1. Dispatcher creates envelope, calls Register(envelope)
/// 2. Receptor processes message, may call eventStore.AppendAsync(streamId, message)
/// 3. EventStore calls TryGetEnvelope(message) to get the envelope
/// 4. Processing completes, Unregister is called (or scope disposes)
/// </remarks>
public interface IEnvelopeRegistry {
/// <summary>
/// Registers an envelope in the registry.
/// The envelope's Payload is used as the key for later lookup.
/// </summary>
void Register<T>(MessageEnvelope<T> envelope);
/// <summary>
/// Attempts to get the envelope for a message.
/// Returns null if the message is not registered (does not throw).
/// </summary>
MessageEnvelope<T>? TryGetEnvelope<T>(T message) where T : notnull;
/// <summary>
/// Unregisters a message from the registry.
/// </summary>
void Unregister<T>(T message) where T : notnull;
/// <summary>
/// Unregisters an envelope from the registry.
/// </summary>
void Unregister<T>(MessageEnvelope<T> envelope);
}
Usage Flow¶
graph TB
S1["1. Dispatcher creates envelope<br/>envelopeRegistry.Register(envelope)"]
S2["2. Receptor receives message (payload only)<br/>Calls eventStore.AppendAsync(streamId, message)"]
S3["3. EventStore needs envelope context<br/>envelope = envelopeRegistry.TryGetEnvelope(message)<br/>Uses envelope.CorrelationId, envelope.Hops, etc."]
S4["4. Processing completes<br/>envelopeRegistry.Unregister(message)"]
S1 --> S2 --> S3 --> S4
style S1 fill:#fff3cd,stroke:#ffc107
style S2 fill:#d4edda,stroke:#28a745
style S3 fill:#fff3cd,stroke:#ffc107
How It Works¶
Reference Identity¶
The registry uses object reference identity, not equality:
Reference Identity
var message = new OrderCreated { OrderId = orderId };
// Same instance - works
registry.Register(envelope);
var found = registry.TryGetEnvelope(message); // ✅ Returns envelope
// Different instance with same data - doesn't work
var copy = new OrderCreated { OrderId = orderId };
var notFound = registry.TryGetEnvelope(copy); // ❌ Returns null
This is intentional - it ensures the exact message being processed is matched.
Scoped Lifetime¶
The registry is typically scoped to a request/operation:
Scoped Lifetime
// Registered as scoped in DI
services.AddScoped<IEnvelopeRegistry, EnvelopeRegistry>();
// Each HTTP request/message processing scope gets its own registry
// Automatically cleaned up when scope ends
Use Cases¶
Event Store Integration¶
The event store uses the registry to get envelope context:
Event Store Integration
public class EventStore : IEventStore {
private readonly IEnvelopeRegistry _envelopeRegistry;
public async Task AppendAsync<TMessage>(
Guid streamId,
TMessage message,
CancellationToken ct = default) {
// Try to get envelope from registry
var envelope = _envelopeRegistry.TryGetEnvelope(message);
if (envelope != null) {
// Use envelope's correlation ID, hops, security context
await StoreWithEnvelopeAsync(streamId, envelope, ct);
} else {
// Create minimal envelope for orphan message
var minimalEnvelope = new MessageEnvelope<TMessage> {
MessageId = MessageId.New(),
Payload = message,
Hops = []
};
await StoreWithEnvelopeAsync(streamId, minimalEnvelope, ct);
}
}
}
Security Scope Propagation¶
Security Scope Propagation
public class ScopePropagatingEventStoreDecorator : IEventStore {
private readonly IEnvelopeRegistry _envelopeRegistry;
private readonly IEventStore _inner;
public async Task AppendAsync<TMessage>(
Guid streamId,
TMessage message,
CancellationToken ct = default) where TMessage : notnull {
var envelope = _envelopeRegistry.TryGetEnvelope(message);
// Propagate security scope from the envelope's current hop (MessageHop.Scope)
var scope = envelope?.GetCurrentScope();
// Apply scope to storage
using (_securityScope.UseContext(scope)) {
await _inner.AppendAsync(streamId, message, ct);
}
}
}
Updated
The shipped SecurityContextEventStoreDecorator in Whizbang.Core does not use the envelope registry — it resolves scope and correlation from the ambient context via CascadeContext.ResolveHopFirstScope/ResolveHopFirstIdentity when appending raw messages. The example above illustrates a custom decorator built on IEnvelopeRegistry.
Correlation Tracking¶
Correlation Tracking
public class CorrelationTrackingReceptor : IReceptor<CreateOrder, OrderCreated> {
private readonly IEnvelopeRegistry _envelopeRegistry;
public async ValueTask<OrderCreated> HandleAsync(
CreateOrder command,
CancellationToken ct = default) {
// Get correlation ID from envelope hops
var envelope = _envelopeRegistry.TryGetEnvelope(command);
var correlationId = envelope?.GetCorrelationId() ?? CorrelationId.New();
_logger.LogInformation(
"Processing CreateOrder with CorrelationId {CorrelationId}",
correlationId);
// Process and return event...
}
}
Implementation Details¶
Thread Safety and Pooling¶
The default implementation uses a pooled Dictionary with ReferenceEqualityComparer and explicit locking. After initial warmup, operations are zero-allocation:
Thread Safety
public sealed class EnvelopeRegistry : IEnvelopeRegistry, IDisposable {
private static readonly ConcurrentBag<Dictionary<object, IMessageEnvelope>> _pool = [];
private readonly Dictionary<object, IMessageEnvelope> _entries;
private readonly Lock _lock = new();
public EnvelopeRegistry() {
// Rent from pool if available, otherwise create with ReferenceEqualityComparer
if (_pool.TryTake(out var dict)) {
_entries = dict;
} else {
_entries = new Dictionary<object, IMessageEnvelope>(
ReferenceEqualityComparer.Instance);
}
}
public void Register<T>(MessageEnvelope<T> envelope) {
lock (_lock) { _entries[envelope.Payload!] = envelope; }
}
public MessageEnvelope<T>? TryGetEnvelope<T>(T message) where T : notnull {
lock (_lock) {
return _entries.TryGetValue(message, out var envelope)
? envelope as MessageEnvelope<T> : null;
}
}
public void Dispose() {
lock (_lock) { _entries.Clear(); }
_pool.Add(_entries); // Return to pool (bounded at 256 pooled dictionaries)
}
}
The ReferenceEqualityComparer.Instance is what enforces reference identity semantics -- TryGetEnvelope will only find the envelope if you pass the exact same object instance that was registered.
Memory Management¶
- Envelopes are stored by reference (not copied)
- Dictionary is pooled to minimize allocations after warmup
- Registry implements
IDisposable-- dictionaries are cleared and returned to the pool on dispose - Registry should be scoped to avoid memory leaks
Best Practices¶
DO¶
- Use scoped lifetime for the registry
- Unregister after processing to free memory
- Check for null when calling
TryGetEnvelope - Pass original message instance - not copies
DON'T¶
- Don't use singleton lifetime - causes memory leaks
- Don't assume envelope exists - always check for null
- Don't modify registered messages - use as immutable
- Don't rely on equality - only reference identity works
Related Documentation¶
- Message Envelopes - Envelope structure
- Envelope Serialization - Serializing envelopes
- Observability - Message tracing
- Message Context - Correlation and causation
Version 1.0.0 - Foundation Release