Skip to content

Envelope Serialization

Verified by tests

EnvelopeSerializerTests — library CI run #31657041675 (2026-08-13)

The EnvelopeSerializer handles conversion between typed message envelopes and their serialized JSON form. It ensures AOT compatibility and proper type metadata preservation.

Overview

When storing or transmitting envelopes, they need to be serialized to JSON. The serializer:

  • Preserves type metadata before serialization
  • Converts to JsonElement for storage
  • Restores typed messages during deserialization
  • AOT compatible - no runtime reflection

EnvelopeSerializer

EnvelopeSerializer

namespace Whizbang.Core.Messaging;

/// <summary>
/// Centralizes envelope serialization/deserialization between typed and JsonElement forms.
/// Ensures envelope type metadata is correctly captured before serialization.
/// </summary>
public sealed class EnvelopeSerializer : IEnvelopeSerializer {
  private readonly JsonSerializerOptions _jsonOptions;

  public EnvelopeSerializer(JsonSerializerOptions? jsonOptions = null) {
    _jsonOptions = jsonOptions ?? new JsonSerializerOptions();
  }

  /// <summary>
  /// Serializes a typed envelope to JsonElement form for storage.
  /// Captures envelope and message type names before serialization.
  /// </summary>
  public SerializedEnvelope SerializeEnvelope<TMessage>(IMessageEnvelope<TMessage> envelope);

  /// <summary>
  /// Deserializes a message payload from a JsonElement envelope.
  /// </summary>
  public object DeserializeMessage(MessageEnvelope<JsonElement> jsonEnvelope, string messageTypeName);
}

IEnvelopeSerializer Interface

IEnvelopeSerializer Interface

namespace Whizbang.Core.Messaging;

/// <summary>
/// Interface for envelope serialization/deserialization service.
/// </summary>
public interface IEnvelopeSerializer {
  /// <summary>
  /// Serializes a typed envelope to JsonElement form for storage.
  /// </summary>
  SerializedEnvelope SerializeEnvelope<TMessage>(IMessageEnvelope<TMessage> envelope);

  /// <summary>
  /// Deserializes a message payload from a JsonElement envelope.
  /// </summary>
  object DeserializeMessage(MessageEnvelope<JsonElement> jsonEnvelope, string messageTypeName);
}

SerializedEnvelope

SerializedEnvelope

namespace Whizbang.Core.Messaging;

/// <summary>
/// Result of envelope serialization containing JsonElement envelope and type metadata.
/// </summary>
/// <param name="JsonEnvelope">The serialized envelope with JsonElement payload</param>
/// <param name="EnvelopeType">Assembly-qualified name of the original typed envelope</param>
/// <param name="MessageType">Assembly-qualified name of the message payload type</param>
public sealed record SerializedEnvelope(
  MessageEnvelope<JsonElement> JsonEnvelope,
  string EnvelopeType,
  string MessageType
);

Serialization Flow

graph TB
    S1["1. Typed Envelope: MessageEnvelope&lt;OrderCreated&gt;<br/>serializer.SerializeEnvelope(envelope)"]
    S2["2. Capture Type Metadata<br/>EnvelopeType: &quot;Whizbang.Core.Observability.MessageEnvelope&#96;1[[OrderCreated,...]], Whizbang.Core&quot;<br/>MessageType: &quot;MyApp.Events.OrderCreated, MyApp&quot;"]
    S3["3. Convert to JsonElement<br/>Serialize envelope to JSON<br/>Deserialize as MessageEnvelope&lt;JsonElement&gt;"]
    S4["4. Return SerializedEnvelope<br/>Contains JsonEnvelope + type metadata"]

    S1 --> S2 --> S3 --> S4

    style S1 fill:#fff3cd,stroke:#ffc107
    style S4 fill:#d4edda,stroke:#28a745

Usage Examples

Serializing for Storage

Serializing for Storage

public class EventStore {
  private readonly IEnvelopeSerializer _serializer;

  public async Task StoreAsync<TMessage>(
      Guid streamId,
      MessageEnvelope<TMessage> envelope,
      CancellationToken ct = default) {

    // Serialize envelope to storage format
    var serialized = _serializer.SerializeEnvelope(envelope);

    // Store in database
    await _db.ExecuteAsync(
        """
        INSERT INTO events (stream_id, message_id, envelope_type, message_type, payload)
        VALUES (@StreamId, @MessageId, @EnvelopeType, @MessageType, @Payload::jsonb)
        """,
        new {
          StreamId = streamId,
          MessageId = envelope.MessageId.Value,
          EnvelopeType = serialized.EnvelopeType,
          MessageType = serialized.MessageType,
          Payload = JsonSerializer.Serialize(serialized.JsonEnvelope)
        },
        ct);
  }
}

Deserializing from Storage

Deserializing from Storage

public async Task<object> LoadMessageAsync(Guid messageId) {
  var row = await _db.QuerySingleAsync<EventRow>(
      "SELECT * FROM events WHERE message_id = @MessageId",
      new { MessageId = messageId });

  // Parse stored JSON to JsonElement envelope
  var jsonEnvelope = JsonSerializer.Deserialize<MessageEnvelope<JsonElement>>(
      row.Payload,
      _jsonOptions);

  // Deserialize to original message type
  var message = _serializer.DeserializeMessage(
      jsonEnvelope!,
      row.MessageType);

  return message;
}

Outbox Integration

Outbox Integration

public async Task WriteToOutboxAsync<TMessage>(
    MessageEnvelope<TMessage> envelope,
    CancellationToken ct = default) {

  var serialized = _serializer.SerializeEnvelope(envelope);

  await _coordinator.StoreOutboxMessagesAsync(
      [
        new OutboxMessage {
          MessageId = envelope.MessageId.Value,
          Envelope = serialized.JsonEnvelope,
          EnvelopeType = serialized.EnvelopeType,
          Metadata = new EnvelopeMetadata {
            MessageId = envelope.MessageId,
            Hops = envelope.Hops
          },
          IsEvent = true
        }
      ],
      partitionCount: 1,
      ct);
}

Double Serialization Prevention

The serializer detects and prevents double serialization:

Double Serialization Prevention

// ❌ This will throw InvalidOperationException
var alreadySerialized = new MessageEnvelope<JsonElement>(...);
serializer.SerializeEnvelope(alreadySerialized);
// Error: "DOUBLE SERIALIZATION DETECTED: Payload is JsonElement..."

This prevents bugs where envelopes are accidentally serialized twice.

AOT Compatibility

The serializer uses JsonContextRegistry for AOT-safe type resolution:

AOT Compatibility

public object DeserializeMessage(
    MessageEnvelope<JsonElement> jsonEnvelope,
    string messageTypeName) {

  var jsonElement = jsonEnvelope.Payload;

  // AOT-safe type resolution via registry (zero reflection)
  var jsonTypeInfo = JsonContextRegistry.GetTypeInfoByName(
      messageTypeName,
      _jsonOptions);

  if (jsonTypeInfo == null) {
    throw new InvalidOperationException(
        $"Failed to resolve message type '{messageTypeName}'. " +
        "Ensure the assembly containing this type is loaded and registered via [ModuleInitializer].");
  }

  return jsonElement.Deserialize(jsonTypeInfo)!;
}

Types are registered via [ModuleInitializer] in generated code.

Error Handling

Type Resolution Failure

Type Resolution Failure

try {
  var message = serializer.DeserializeMessage(jsonEnvelope, messageTypeName);
} catch (InvalidOperationException ex) {
  // "Failed to resolve message type 'MyApp.Events.OldEvent'..."
  _logger.LogError(ex, "Cannot deserialize unknown message type");
}

Serialization Failure

Serialization Failure

try {
  var serialized = serializer.SerializeEnvelope(envelope);
} catch (InvalidOperationException ex) {
  // "DOUBLE SERIALIZATION DETECTED..." or
  // "Message type ... must have an assembly-qualified name"
  _logger.LogError(ex, "Envelope serialization failed");
}

Best Practices

DO

  • Use IEnvelopeSerializer instead of direct JSON serialization
  • Store type metadata alongside serialized payload
  • Register message types via JsonContextRegistry
  • Handle deserialization failures gracefully

DON'T

  • Don't serialize envelopes twice - causes data corruption
  • Don't use reflection for type resolution - breaks AOT
  • Don't assume types exist - handle missing type errors
  • Don't modify JsonElement payloads - they're read-only

Version 1.0.0 - Foundation Release