Repository navigation
Serialization
Whatever you register as ISerializer serializes the message bodies. The default is JsonSerializer, which uses Newtonsoft.Json.
Newtonsoft stays the default on purpose. 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.
These numbers were measured on net10, for a message whose body is a 256-byte string. The serialized form is larger than the body, because 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 |
That is 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.
System.Text.Json is in-box, so there is no new dependency to take.
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. On a queue that already holds messages, you have to deal with it.
Every message carries a Queue-SerializerId header that names the serializer that wrote its body. The consumer reads the headers before the body (the headers already carry the interceptor graph). As a result, the consumer knows the right serializer before it deserializes the body.
ISerializerResolver does the lookup. No transport is involved: resolution happens in RootSerializer, and every transport reaches deserialization through it.
This lets a single queue hold messages written by more than one serializer. It is also what makes switching serializers possible at all.
If a message names a serializer that is not registered, the library throws rather than guessing. That is deliberate. Reading a body with the wrong serializer does not reliably fail, and it can hand back a half-populated object. A poison message is far easier to diagnose than silent data loss.
Messages enqueued before this feature shipped carry no Queue-SerializerId header. Those messages fall back to ISerializerResolver.Fallback, which defaults to the serializer registered for the queue. That is exactly the behaviour that applied before, when 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 and select the serializer per message.
- After the backlog of unmarked messages drains, the fallback no longer matters.
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. The serializer handles those, and tests cover them.
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. If the header envelope were pluggable, a consumer would need to know how the headers were written before it could 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