IMessageSerializer
Overview
Section titled “Overview”IMessageSerializer is the interface the framework calls to turn a CLR Message object into bytes on the way out and back into a typed object on the way in. The shipped default is JSON-based (SystemTextJsonMessageSerializer, internal to the framework). Replace it when you need a binary wire format (Protobuf, MessagePack) or a custom serialization scheme that the default implementation does not support.
A serializer is typically paired with an IMessageTypeRegistry. Some transports embed the CLR type name in a message header and call Deserialize(data, Type) directly, so the serializer never needs to discover the type from the payload. Other wire formats encode the type name inside the payload itself — in those cases the serializer calls IMessageTypeRegistry.TryResolve to map the wire name back to a CLR type.
See Messages for the conceptual model.
Reference
Section titled “Reference”public interface IMessageSerializer{ void Serialize<T>(T message, IBufferWriter<byte> output) where T : Message; T Deserialize<T>(ReadOnlyMemory<byte> data) where T : Message; object Deserialize(ReadOnlyMemory<byte> data, Type type); object Deserialize(in ReadOnlySequence<byte> data, Type type);}The interface has four members — three abstract and one default-interface method. The ReadOnlySequence<byte> deserialize is implemented in terms of Deserialize(ReadOnlyMemory<byte>, Type) by default and only needs to be overridden when zero-copy handling of multi-segment sequences is required.
Serialize<T>
Section titled “Serialize<T>”void Serialize<T>(T message, IBufferWriter<byte> output) where T : Message;Serializes message directly into output. Implementations should write without allocating an intermediate byte[].
Parameters
message— the message to serialize; constrained toMessagesubclasses.output— the destination buffer writer. Caller owns its lifetime; the implementation callsoutput.GetSpan/output.Advance(or equivalent) to write without copying.
Deserialize<T>
Section titled “Deserialize<T>”T Deserialize<T>(ReadOnlyMemory<byte> data) where T : Message;Deserializes a memory block into an instance of T. Used on the request/reply path where the caller has the static type at the call site.
Parameters
data— the serialized payload.
Returns. A fully populated T instance.
Deserialize(ReadOnlyMemory<byte>, Type)
Section titled “Deserialize(ReadOnlyMemory<byte>, Type)”object Deserialize(ReadOnlyMemory<byte> data, Type type);Deserializes a memory block into the CLR type specified by type. The dispatch path resolves the runtime type from the message header (via IMessageTypeRegistry) and calls this overload directly.
Parameters
data— the serialized payload.type— the destination CLR type; must be aMessagesubclass.
Returns. The deserialized object, typed as object.
Deserialize(in ReadOnlySequence<byte>, Type)
Section titled “Deserialize(in ReadOnlySequence<byte>, Type)”object Deserialize(in ReadOnlySequence<byte> data, Type type);Default-interface method. Deserializes a (possibly multi-segment) ReadOnlySequence<byte> into the CLR type specified by type. Used by the streaming path where buffered packets arrive as segmented sequences rather than copied into a contiguous buffer.
Default implementation. Flattens the sequence into a byte[] via BuffersExtensions.ToArray before delegating to Deserialize(ReadOnlyMemory<byte>, Type). Implementations that can read across segments without flattening — e.g. via System.Text.Json.Utf8JsonReader on a sequence — should override to avoid the allocation on multi-segment input.
Parameters
data— the serialized payload, possibly spanning multiple segments.type— the destination CLR type.
Returns. The deserialized object, typed as object.
Implementing
Section titled “Implementing”Thread-safety
Section titled “Thread-safety”IMessageSerializer is resolved as a singleton. Every message processed by the bus — inbound and outbound — flows through the same instance. Implementations must be thread-safe. Stateless implementations (those that hold only read-only configuration) are safe by default. If the implementation holds mutable state (for example, a write buffer pool), protect shared state with a ConcurrentQueue or similar mechanism.
Type-id propagation
Section titled “Type-id propagation”The bus pipeline discovers the CLR type to deserialize into by one of two paths:
- Header-based: the transport reads a type name from a message header and calls
Deserialize(data, Type)directly. The serializer does not need to embed or read a type discriminator in the payload. This is the simpler path and works well with any wire format. - Payload-embedded: the serializer embeds a type discriminator (e.g. the wire name) in the payload on serialize and reads it back on deserialize. On the deserialize side, call
IMessageTypeRegistry.TryResolve(typeName, out var type)to obtain the CLR type; ifTryResolvereturnsfalse, throw so the pipeline routes the message to the error queue.
Both paths are valid. Choose the header-based approach unless the protocol mandates an embedded type id.
Versioning
Section titled “Versioning”Serialization format changes affect already-persisted messages — timeout state, aggregator snapshots, and messages sitting in dead-letter queues may have been written with an older format. When evolving a wire format:
- Adding new optional fields is safe; absent fields should deserialize to their default value.
- Removing fields or changing their wire ordering breaks replay of older messages.
- Consider a version discriminator in the payload if you need to support multiple format versions simultaneously.
Error-handling contract
Section titled “Error-handling contract”Throw a descriptive exception on malformed or unrecognizable payloads. Do not return a default instance or swallow the error — throwing causes the bus pipeline to route the message to the error queue, where it can be inspected and retried. Swallowing an error would silently discard the message.
Skeletal Protobuf implementation
Section titled “Skeletal Protobuf implementation”using System.Buffers;using ServiceConnect.Interfaces;
public sealed class ProtobufMessageSerializer : IMessageSerializer{ public void Serialize<T>(T message, IBufferWriter<byte> output) where T : Message { throw new NotImplementedException(); }
public T Deserialize<T>(ReadOnlyMemory<byte> data) where T : Message { throw new NotImplementedException(); }
public object Deserialize(ReadOnlyMemory<byte> data, Type type) { throw new NotImplementedException(); }
// Deserialize(in ReadOnlySequence<byte>, Type) has a default implementation // on the interface that flattens to a byte[] before delegating. Override only // if you can read multi-segment sequences without copying (e.g. Utf8JsonReader).}Protobuf-backed serializer
Section titled “Protobuf-backed serializer”The following example implements the header-based strategy: the type name travels in a transport message header, so the pipeline resolves the CLR type before calling Deserialize and passes it as an explicit argument. The serializer payload contains only the Protobuf bytes — no type discriminator is embedded. The serializer therefore needs no registry and has no constructor dependencies.
using System.Buffers;using Google.Protobuf;using ServiceConnect.Interfaces;
/// <summary>/// Wire format: pure Protobuf bytes. The CLR type is carried in a transport/// header and resolved by the pipeline before Deserialize is called./// </summary>public sealed class ProtobufMessageSerializer : IMessageSerializer{ public void Serialize<T>(T message, IBufferWriter<byte> output) where T : Message { var proto = (IMessage)message; var payload = proto.ToByteArray(); var span = output.GetSpan(payload.Length); payload.AsSpan().CopyTo(span); output.Advance(payload.Length); }
public T Deserialize<T>(ReadOnlyMemory<byte> data) where T : Message => (T)Deserialize(data, typeof(T));
public object Deserialize(ReadOnlyMemory<byte> data, Type type) { var parser = (MessageParser)type.GetProperty("Parser")!.GetValue(null)!; // Protobuf parsers expect a span/array; the framework guarantees the memory // is contiguous and stable for the duration of this call. return parser.ParseFrom(data.Span); }}Register the serializer during bus startup (register a ProtobufMessageTypeRegistry alongside it if your transport needs one — see IMessageTypeRegistry for the full registry example):
services.AddServiceConnect(builder =>{ builder.AddRegistration(svc => { svc.AddSingleton<IMessageSerializer, ProtobufMessageSerializer>(); svc.AddSingleton<IMessageTypeRegistry, ProtobufMessageTypeRegistry>(); });});Or wrap the registration in an extension method for cleaner startup code:
public static class ProtobufSerializationExtensions{ public static ServiceConnectBuilder UseProtobufSerialization(this ServiceConnectBuilder builder) { builder.AddRegistration(svc => { svc.AddSingleton<IMessageSerializer, ProtobufMessageSerializer>(); svc.AddSingleton<IMessageTypeRegistry, ProtobufMessageTypeRegistry>(); }); return builder; }}
// Startup:services.AddServiceConnect(builder =>{ builder.UseRabbitMQ(transport => transport.Host = "rabbit.internal.example"); builder.UseProtobufSerialization();});When an OrderPlaced message is published by OrderService, the pipeline calls Serialize<OrderPlaced>, producing a Protobuf byte array. On the receiving side, the transport reads the CLR type name from the message header, resolves it to typeof(OrderPlaced) (via the registry or a direct lookup), and calls Deserialize(data, typeof(OrderPlaced)) to reconstruct the message before dispatching to the handler.
See also
Section titled “See also”- Messages — concept
IMessageTypeRegistry— companion registry interfaceEnvelope— the message wrapper the transport works with