Skip to main content

Serialization

Dispatch supports multiple serialization formats for messages. Choose based on your needs for performance, interoperability, and debugging.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the required package:
    dotnet add package Excalibur.Dispatch
  • For alternative serializers, install the provider package (e.g., Excalibur.Dispatch.Serialization.MemoryPack)
  • Familiarity with middleware concepts and pipeline stages

Two-Layer Serialization Architecture

Dispatch uses two independent serialization layers. Understanding this distinction helps you choose the right configuration:

LayerInterfaceWhat It SerializesDefaultConsumer Attributes Needed?
Event storageIEventSerializerYour domain events (POCOs)JsonEventSerializer (System.Text.Json)No
Envelope transportISerializer + IBinaryEnvelopeDeserializerInternal OutboxEnvelope / InboxEnvelope wrappersNone (JSON fallback)No

How the layers work together:

Your Event (plain POCO)
→ IEventSerializer.Serialize() → byte[] payload
→ wrapped in [MemoryPackable] OutboxEnvelope { Payload = byte[] }
→ ISerializer (MemoryPack) serializes the envelope → stored/transmitted

The envelope layer is an internal implementation detail. Your consumer event types never need [MemoryPackable], [MessagePackObject], or any serializer-specific attributes -- they remain plain POCOs regardless of which serializer you choose.

AddDispatch() auto-registers JsonEventSerializer as IEventSerializer via TryAddSingleton. Calling services.AddMemoryPackSerializer() registers MemoryPack for the envelope layer only -- IEventSerializer stays as JSON unless you explicitly override it.

Serializers

SerializerPackageBest For
System.Text.Json (default)Built-inCross-language, debugging
MemoryPackExcalibur.Dispatch.Serialization.MemoryPack.NET-only, max performance
MessagePackExcalibur.Dispatch.Serialization.MessagePackCross-language, compact
ProtobufExcalibur.Dispatch.Serialization.ProtobufSchema-based, gRPC compat
AvroExcalibur.Dispatch.Serialization.AvroSchema-based, Kafka/Hadoop

Configuration

Default (System.Text.Json)

services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});

// Default: JSON (System.Text.Json) is used for serialization.
// No extra configuration needed -- works with any POCO event type.

Custom JSON Options

services.AddJsonSerialization(options =>
{
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.WriteIndented = false;
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});

MemoryPack (Fastest)

dotnet add package Excalibur.Dispatch.Serialization.MemoryPack
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});

// One call registers MemoryPack, adds it to the serializer registry, and sets it as current
services.AddMemoryPackSerializer();
No attributes needed on your events

Consumer event types do not need [MemoryPackable] or any serializer-specific attributes. Only the internal envelope wrapper uses MemoryPack attributes. Your domain events remain plain POCOs.

MessagePack

dotnet add package Excalibur.Dispatch.Serialization.MessagePack
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});

// One call does everything: DI registration, serializer registry, set as current
services.AddMessagePackSerializer();

Protobuf

dotnet add package Excalibur.Dispatch.Serialization.Protobuf
services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});

// One call does everything: DI registration, serializer registry, set as current
services.AddProtobufSerializer();

See Serialization Providers for detailed provider configuration including Avro, native options, and custom option overloads.

Compression

Enable Compression

Compression can be configured via serialization options:

services.AddJsonSerialization(options =>
{
// Configure compression settings on JSON options
options.SerializerOptions.WriteIndented = false; // Compact output
});

// Or configure compression at the transport level via the builder
services.AddDispatch(dispatch =>
{
dispatch.UseKafka(kafka =>
{
kafka.CompressionType(CompressionType.Gzip);
});
});

Encryption

Transport Encryption

Encryption is handled by the Excalibur.Dispatch.Security package, not the serializer:

services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
dispatch.UseMiddleware<MessageEncryptionMiddleware>();

// Configure encryption via IConfiguration
dispatch.UseSecurity(configuration);
});

Field-Level Encryption

Field-level encryption uses the [EncryptedField] attribute on byte[] properties. For string data, serialize to bytes first:

using Excalibur.Dispatch.Compliance;

public class CustomerProjection
{
public string Id { get; set; }
public string Name { get; set; }

[EncryptedField]
public byte[] SocialSecurityNumber { get; set; }

[EncryptedField(Purpose = "pci-data")]
public byte[] CreditCardData { get; set; }
}

See Security for encryption architecture details.

AOT Compatibility

Source Generators

// Enable source generation for AOT
[JsonSerializable(typeof(CreateOrderAction))]
[JsonSerializable(typeof(OrderCreatedEvent))]
public partial class AppJsonContext : JsonSerializerContext { }

services.AddJsonSerialization(options =>
{
options.SerializerOptions.TypeInfoResolver = AppJsonContext.Default;
});

MemoryPack AOT

MemoryPack is AOT-friendly via source generation. The internal envelope uses [MemoryPackable]; your consumer event types do not need any attributes.

Multi-Format Support

Per-Transport Serialization

Use transport routing with the pluggable serialization system to route different message types to transports that use different serializers:

services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);

dispatch.UseRouting(routing =>
{
routing.Transport
// Kafka: high-volume events
.Route<HighVolumeEvent>().To("kafka")
// External API: interop events
.Route<ExternalEvent>().To("http")
.Default("rabbitmq");
});
});

// Register the serializer you want for internal persistence
services.AddMessagePackSerializer();

Custom Serializers

Implementing ISerializer

using System.Buffers;
using Excalibur.Dispatch.Abstractions.Serialization;

public class CustomSerializer : ISerializer
{
public string Name => "CustomFormat";
public string Version => "1.0";
public string ContentType => "application/x-custom";

public void Serialize<T>(T value, IBufferWriter<byte> bufferWriter)
{
var bytes = CustomFormat.Serialize(value);
bufferWriter.Write(bytes);
}

public T Deserialize<T>(ReadOnlySpan<byte> data)
{
return CustomFormat.Deserialize<T>(data);
}

public byte[] SerializeObject(object value, Type type)
{
return CustomFormat.Serialize(value, type);
}

public object DeserializeObject(ReadOnlySpan<byte> data, Type type)
{
return CustomFormat.Deserialize(data, type);
}
}

Registration

For custom serializers, use the AddPluggableSerializer extension method to register with the serializer registry:

services.AddPluggableSerializer(200, new MyCustomSerializer(), setAsCurrent: true);

Pool-Backed Serialization

For high-throughput event sourcing scenarios, IEventSerializer provides Span-based overloads via EventSerializerExtensions that use ArrayPool<byte> to minimize allocations during serialization.

When to Use

ScenarioRecommendation
High-volume event storesUse Span-based extensions
Event replay/projectionUse Span-based extensions
Standard message dispatchStandard serializers sufficient
Cross-language systemsUse standard JSON

EventSerializerExtensions

Span-based overloads are extension methods on IEventSerializer:

public static class EventSerializerExtensions
{
// Serialize to pre-allocated span (pool-backed, low-allocation)
public static int SerializeEvent(this IEventSerializer s, IDomainEvent evt, Span<byte> buffer);

// Deserialize from span
public static IDomainEvent DeserializeEvent(this IEventSerializer s, ReadOnlySpan<byte> data, Type type);

// Get serialized size for buffer allocation
public static int GetEventSize(this IEventSerializer s, IDomainEvent evt);
}

Usage Pattern

// 1. Get the serializer
var serializer = serviceProvider.GetRequiredService<IEventSerializer>();

// 2. Estimate buffer size
var size = serializer.GetEventSize(domainEvent);

// 3. Rent buffer from pool (pool-backed, low-allocation)
var buffer = ArrayPool<byte>.Shared.Rent(size);
try
{
// 4. Serialize to rented buffer
var written = serializer.SerializeEvent(domainEvent, buffer);

// 5. Use the serialized data
await eventStore.AppendAsync(buffer.AsSpan(0, written), ct);
}
finally
{
// 6. Return buffer to pool
ArrayPool<byte>.Shared.Return(buffer);
}

SpanEventSerializer Implementation

The built-in SpanEventSerializer implements IEventSerializer and wraps the pluggable serialization infrastructure:

// SpanEventSerializer delegates to the configured ISerializer
// Prefers the current/default serializer (JSON-first per ADR-295),
// falls back to MemoryPack only if no current serializer is configured
public SpanEventSerializer(ISerializerRegistry registry)
{
_serializer = registry.GetCurrent().Serializer
?? registry.GetByName("MemoryPack")
?? registry.GetById(SerializerIds.MemoryPack);
}

Performance Characteristics

OperationStandard byte[] APISpan-based Extensions
Allocations per serialize1-3 byte[]0 (pooled)
Allocations per deserialize1 byte[] copy0 (span-based)
GC pressureModerateMinimal
Best forGeneral useHigh-throughput event stores

Integration with Event Stores

public class HighPerformanceEventStore : IEventStore
{
private readonly IEventSerializer _serializer;

public async Task AppendAsync(
Guid streamId,
IReadOnlyList<IDomainEvent> events,
CancellationToken ct)
{
foreach (var evt in events)
{
var size = _serializer.GetEventSize(evt);
var buffer = ArrayPool<byte>.Shared.Rent(size);
try
{
var written = _serializer.SerializeEvent(evt, buffer);
await PersistAsync(streamId, buffer.AsSpan(0, written), ct);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
}

Both APIs Available

EventSerializerExtensions adds Span-based methods alongside the core byte[] API:

IEventSerializer serializer = ...;

// Span-based API via extension (pool-backed, low-allocation)
int written = serializer.SerializeEvent(evt, buffer.AsSpan());

// Core byte[] API (always available)
byte[] data = serializer.SerializeEvent(evt);

Performance Comparison

SerializerSerializeDeserializeSizeAllocations
ZeroAlloc + MemoryPack0.8x0.9x1x0
MemoryPack1x1x1x1-2
MessagePack2.5x2x1.1x2-3
System.Text.Json4x3x1.5x3-5
Newtonsoft.Json6x5x1.5x5-8
Protobuf3x2.5x0.9x2-3

Relative performance - lower is better. Actual results vary by payload.

ZeroAlloc Benchmark Suite

For detailed benchmarks comparing ZeroAlloc vs JSON serialization, run:

cd benchmarks/Excalibur.Dispatch.Benchmarks
dotnet run -c Release --filter *SpanEventSerializer*

Benchmark Categories:

  • JSON vs ZeroAlloc comparison (small and large events)
  • Buffer pooling allocation verification
  • Event sourcing scenarios (100-event replay/append)
  • Round-trip benchmarks

See the Performance Best Practices for detailed optimization guidance.

Best Practices

ScenarioRecommendation
High-throughput event storesZeroAlloc + MemoryPack
Event replay/projectionsZeroAlloc + MemoryPack
.NET-only, high performanceMemoryPack
Cross-languageSystem.Text.Json or MessagePack
Schema evolutionProtobuf
DebuggingSystem.Text.Json with WriteIndented
Large payloadsEnable compression
Sensitive dataEnable encryption
AOT deploymentMemoryPack or System.Text.Json with source gen

Next Steps

See Also

  • Serialization Providers - Detailed provider configuration for MemoryPack, MessagePack, Protobuf, Avro, and pluggable serialization
  • Middleware Overview - How serialization middleware fits into the Dispatch pipeline