-
Notifications
You must be signed in to change notification settings - Fork 16
Serialization
Message bodies are serialized by whatever is registered as ISerializer. The default is JsonSerializer, which uses Newtonsoft.Json.
Newtonsoft stays the default deliberately. You can queue any POCO you like, and Newtonsoft handles a wider range of shapes with no annotation than the alternatives do. SystemTextJsonSerializer is available for callers who own their message types and want the allocation back.
This is separate from IExpressionSerializer, which the LINQ and method queues use for compiled expressions. Nothing on this page affects it.
Measured on net10, for a message whose body is a 256-byte string. The serialized form is larger than the body — it also carries the wrapper and the type name.
| Newtonsoft | System.Text.Json | |
|---|---|---|
| serialize | 682 ns / 4,464 B | 270 ns / 752 B |
| deserialize | 768 ns / 4,552 B | 407 ns / 1,272 B |
About 7 KB less garbage per message round trip, on every transport. The send half alone is roughly a fifth of what an entire SQLite send allocates.
There is no new dependency to take — System.Text.Json is in-box.
Register it during transport initialization:
using var queueContainer = new QueueContainer<SqLiteMessageQueueInit>(serviceRegister =>
serviceRegister.Register<ISerializer, SystemTextJsonSerializer>(LifeStyles.Singleton));That changes what writes new messages. Reading is covered below, and on a queue that already holds messages you have to deal with it.
Every message carries a Queue-SerializerId header naming the serializer that wrote its body. The consumer reads the headers before the body — they already carry the interceptor graph — so by the time a body is deserialized the right serializer is known.
ISerializerResolver does the lookup. No transport is involved: resolution happens in RootSerializer, which every transport reaches deserialization through.
This is what lets a single queue hold messages written by more than one serializer, and it is what makes switching serializers possible at all.
A message naming a serializer that is not registered throws rather than guessing. That is deliberate — reading a body with the wrong serializer does not reliably fail, and can hand back a half-populated object. A poison message is far easier to diagnose than silent data loss.
Anything enqueued before this feature shipped carries no Queue-SerializerId header. Those fall back to ISerializerResolver.Fallback, which defaults to the serializer registered for the queue — exactly the behaviour that applied before, where whatever was registered read everything.
⚠️ If you change the serializer on a queue that already holds messages, point the fallback at whatever wrote them, or those messages become unreadable.
using var queueContainer = new QueueContainer<SqLiteMessageQueueInit>(serviceRegister =>
{
var binder = new DenyListSerializationBinder();
// what writes new messages
serviceRegister.Register<ISerializer>(() => new SystemTextJsonSerializer(binder),
LifeStyles.Singleton);
// what reads them back, including the ones already in the queue
serviceRegister.Register<ISerializerResolver>(() =>
{
var resolver = new SerializerResolver(new SystemTextJsonSerializer(binder));
resolver.SetFallback(new JsonSerializer(binder)); // wrote the existing backlog
return resolver;
}, LifeStyles.Singleton);
});Teach the consumers to read the new format first, then switch the producers.
- Deploy consumers that register both serializers, still writing with the old one.
- Switch the producers to the new serializer. Consumers read both, selecting per message.
- Once the backlog of unmarked messages has drained, the fallback stops mattering.
Both come from System.Text.Json itself rather than from this library.
A property declared as a concrete base class, holding a derived instance, loses the derived part. Newtonsoft writes a type marker whenever the runtime type differs from the declared one; System.Text.Json needs the derived types declared up front:
[JsonDerivedType(typeof(Animal), "animal")]
[JsonDerivedType(typeof(Dog), "dog")]
public class Animal { }
public class Dog : Animal { }Properties declared as object, as an interface, or as an abstract class need no annotation — those are handled and are covered by tests.
Properties with private setters are not restored. The Newtonsoft message serializer does not restore them either, so this matches existing behaviour rather than changing it.
SystemTextJsonSerializer resolves types through the same ISerializationBinder the Newtonsoft path uses, so the configured allow or deny list governs both identically. Replacing DenyListSerializationBinder with AllowListSerializationBinder affects both.
IInternalSerializer, which writes the headers, stays on Newtonsoft. The serializer marker lives in the headers, so making the header envelope pluggable would mean needing to know how the headers were written in order to read how they were written.
A System.Text.Json header path was prototyped and does work, so the obstacle is versioning rather than feasibility.
- Message Interception — interceptors run after serialization, on the serialized bytes
-
docs/serializers.mdin the repository, which carries the same material alongside the code
For any issues please use the GitHub issues