Skip to content

Add message context to serialization interfaces#1082

Merged
mtmk merged 25 commits into
release/3.0from
serializer-with-headers
Apr 16, 2026
Merged

Add message context to serialization interfaces#1082
mtmk merged 25 commits into
release/3.0from
serializer-with-headers

Conversation

@mtmk

@mtmk mtmk commented Mar 3, 2026

Copy link
Copy Markdown
Member

Adds opt-in INatsSerializeWithContext<T> and INatsDeserializeWithContext<T> interfaces (and a combined INatsSerializerWithContext<T>) that receive a NatsMsgContext (subject, reply-to, headers) during (de)serialization. Existing serializers continue to work unchanged: the library dispatches via runtime type check and falls back to the standard interface otherwise. Built-in serializers (NatsUtf8PrimitivesSerializer, NatsRawSerializer, NatsJsonContextSerializer) also propagate the context to _next so chains keep context-awareness at the leaf. NatsMsgContext is constructed via a required-subject constructor (no init setters, no netstandard IsExternalInit shim). The read-only flag on NatsHeaders is removed so context-aware serializers can mutate headers (e.g. set Content-Type); the trade-off is that a single NatsHeaders instance is no longer safe to share across concurrent publishes, documented on the type.

Comment thread src/NATS.Client.Core/NatsMsg.cs Outdated
{
var bufferWriter = new NatsPooledBufferWriter<byte>(SerializationBufferSize);
Serializer.Serialize(bufferWriter, Data);
Serializer.Serialize(bufferWriter, Data, null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be passing in the Headers property?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed.

@github-actions

github-actions Bot commented Mar 4, 2026

Copy link
Copy Markdown

@mtmk

mtmk commented Mar 4, 2026

Copy link
Copy Markdown
Member Author

Hey @adminnz, I added DIMs as well for BC. the idea is for new implementations on net8+ nothing changes. you only implement the new method. the old one is also marked as obsolete. little bit of maintenance burden on our library side but i think it's easily manageable. wdyt? if you're happy please let me know, I can merge (after fixing the tests etc.) and have it released in 3.0 previews today or tomorrow.

@mtmk
mtmk requested a review from adminnz March 4, 2026 12:53
@mtmk mtmk self-assigned this Mar 4, 2026
@mtmk mtmk changed the title [PROPOSAL] Add support for optional NATS headers in serialization/deserialization Add optional NATS headers support to serialization interfaces Mar 4, 2026
@mtmk mtmk mentioned this pull request Mar 4, 2026
1 task
Comment thread src/NATS.Client.Core/NatsHeaders.cs Outdated
[SuppressMessage("StyleCop.CSharp.LayoutRules", "SA1513:Closing brace should be followed by blank line", Justification = "Keep class format as is for reference")]
[SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1214:Readonly fields should appear before non-readonly fields", Justification = "Keep class format as is for reference")]
public class NatsHeaders : IDictionary<string, StringValues>
public class NatsHeaders : INatsHeaders, IDictionary<string, StringValues>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove IDctionary<string,StringValues> as INatsHeaders interface is already inheriting IDictionary<string,StringValues>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@adminnz

adminnz commented Mar 4, 2026

Copy link
Copy Markdown

That is a novel solution for the backwards compatibility problem.
From my experience working on very large code bases (especially in a pub/sub environment) its best to have a really tight feedback loop for the developer, and identify problems as early on in the project/product lifecycle.
Problems can occur at many points in the lifecycle.

  1. Compile Time
  2. Unit Tests (not all projects have tests)
  3. Run Time (App Start)
  4. Run Time (Specific Condition)

While the DIM allows non-breaking changes to library users with existing INatsSerialize/INatsDeserialize implementations, it does mean that a developer now has to not only run their app, they have to trigger the exact code path (stage 4) to realize they might not have implemented either of the interface methods.

It is noble to try and never introduce breaking changes, the "major" in semantic versioning is:

MAJOR version when you make incompatible API changes

Obviously that doesn't mean you have to introduce breaking changes, but it does provide the opportunity to introduce breaking changes.

In my opinion, it would be better to have 3.0 introduce a breaking change and require the header interface variants are implemented.

@mtmk

mtmk commented Mar 5, 2026

Copy link
Copy Markdown
Member Author

@adminnz thanks for the feedback! I've updated the approach; the old methods no longer have DIMs so any implementor missing them gets a compile error.

However the new header overloads still keep DIMs for BC.

On the semver point agreed that 3.0 is the right place for breaking changes but the main reason we want to keep the DIM on the new overloads is transitive dependencies: third-party libraries (or e.g. company internal common libraries) implementing INatsSerialize<T> would also need to release updates before users can upgrade to 3.0. Keeping the old methods as obsolete with DIMs on the new overloads gives the ecosystem a smoother migration path without sacrificing compile-time safety. I realize netstandard would still be breaking and that's something we have to accept balancing complexity, maintenance and given its usage is slowly getting less and less. Please also see the discussion here #1061

@rickdotnet

rickdotnet commented Mar 8, 2026

Copy link
Copy Markdown
Collaborator

Your approach looks fine to me. I also prefer obvious breaking changes over random side-effects and major versions seem appropriate for that. Seems the shims are necessary.

I had started a take on this but never got it pushed. I went with johnweldon's approach and used separate interfaces to keep compatibility. Serializers can opt in if they want headers.

Here's what I had, might add some inspiration.

/// <summary>
/// Deserializer interface for NATS messages.
/// </summary>
/// <typeparam name="T">Deserialized object type</typeparam>
public interface INatsDeserialize<out T>
{
    T? Deserialize(in ReadOnlySequence<byte> buffer);
}

/// <summary>
/// Extended deserializer interface that supports receiving headers during deserialization.
/// </summary>
public interface INatsDeserializeWithHeaders<out T> : INatsDeserialize<T>
{
    /// <summary>
    /// Deserialize value from buffer with headers.
    /// </summary>
    /// <param name="buffer">Buffer with the serialized data.</param>
    /// <param name="headers">Headers from the message.</param>
    /// <returns>Deserialized object</returns>
    T? Deserialize(in ReadOnlySequence<byte> buffer, INatsHeaders headers);
}

Extension methods to prevent type-check spam.

public static class NatsSerializationExtensions
{
    /// <summary>
    /// Serializes the value with header support, falling back to standard serialization if headers are not supported.
    /// </summary>
    public static void Serialize<T>(this INatsSerialize<T> serializer, IBufferWriter<byte> bufferWriter, T value, INatsHeaders? headers)
    {
        if (serializer is INatsSerializeWithHeaders<T> withHeaders && headers != null)
        {
            withHeaders.Serialize(bufferWriter, value, headers);
            return;
        }

        // Fallback to standard serialization
        serializer.Serialize(bufferWriter, value);
    }

    /// <summary>
    /// Deserializes the value with header support, falling back to standard deserialization if headers are not supported.
    /// </summary>
    public static T? Deserialize<T>(this INatsDeserialize<T> deserializer, in ReadOnlySequence<byte> buffer, INatsHeaders? headers)
    {
        if (deserializer is INatsDeserializeWithHeaders<T> withHeaders)
            return withHeaders.Deserialize(buffer, headers ?? new NatsHeaders());

        return deserializer.Deserialize(buffer);
    }
}

@mtmk

mtmk commented Mar 9, 2026

Copy link
Copy Markdown
Member Author

@rickdotnet

INatsDeserializeWithHeaders

nice. this means we also cover netstandard as another first class target without breaking backward compatibility. cc @johnweldon

@adminnz

adminnz commented Mar 13, 2026

Copy link
Copy Markdown

Yes that looks like a good solution.

mtmk added 3 commits March 13, 2026 12:41
The CS0618 suppressions were leftover from the DIM approach
where interface members were marked obsolete. No longer needed
with the separate interface design.
@mtmk
mtmk requested a review from scottf March 31, 2026 10:35
Add unit tests for NatsSerializationExtensions covering both
the header-aware path and the fallback path for serialize and
deserialize. Make the ABI check program assert correctness
instead of just printing, including the new WithHeaders
interfaces.
@mtmk
mtmk requested review from johnweldon and rickdotnet March 31, 2026 12:04
Remove incorrect DIM claims from serialization docs, rewrite the
headers section to describe the WithContext interface design. Clean
up NativeAot example that had leftover INatsHeaders parameter
overloads from the old DIM approach. Restore dropped <returns>
doc comment on INatsDeserialize<T>.Deserialize.

@rickdotnet rickdotnet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me.

Should serializers be able to produce/add headers during serialization? Looks like this supports header-aware deserialization, not header-producing serialization. Seems appropriate to me unless there is a use-case worth tackling now.

@adminnz re: typed vs bytes - valid architectural discussion for a future layer, but this PR doesn't block that path.

Would something like this work?

public class CustomClient
{
    private readonly INatsConnection _conn;
    public INatsSendMsgFactory Factory { get; set; }
    
    public async Task PublishAsync(object payload, NatsHeaders? headers = null)
    {
        var msg = Factory.Create(payload, headers);  // Subject from convention
        await _conn.PublishAsync(msg.Subject, msg.Payload, msg.Headers);
    }
}

@scottf scottf left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@adminnz

adminnz commented Apr 3, 2026

Copy link
Copy Markdown

Looks good to me.

Should serializers be able to produce/add headers during serialization? Looks like this supports header-aware deserialization, not header-producing serialization. Seems appropriate to me unless there is a use-case worth tackling now.

@adminnz re: typed vs bytes - valid architectural discussion for a future layer, but this PR doesn't block that path.

Would something like this work?

public class CustomClient
{
    private readonly INatsConnection _conn;
    public INatsSendMsgFactory Factory { get; set; }
    
    public async Task PublishAsync(object payload, NatsHeaders? headers = null)
    {
        var msg = Factory.Create(payload, headers);  // Subject from convention
        await _conn.PublishAsync(msg.Subject, msg.Payload, msg.Headers);
    }
}

Serialization (producing) should definitely be able to add to headers, otherwise sending is seriously gimped.

And yes a higher level client can the provide very easy to use apis

@mtmk

mtmk commented Apr 3, 2026

Copy link
Copy Markdown
Member Author

@adminnz re separating typed serialization from core: I agree a higher-level client with a factory pattern is a useful abstraction and nothing in here prevents building that on top of INatsConnection. The reason serialization is baked into core is zero-copy performance. Serializers write directly into a pooled IBufferWriter<byte> that goes straight onto the wire, no intermediate allocation. Your example materializes the payload as a string/byte array before handing it to NatsMsg which is an extra allocation + copy. you could change the factory to use an IBufferWriter, but then we're basically back to what current serializers does. Happy to explore a higher-level typed client in a separate issue if you want to open one.

Re header mutating serialization: good catch, it doesn't actually work yet. headers get locked to read-only before it reaches the serializer. the fix is to move SetReadOnly() after serialization. That said, the read-only flag was always a weak guarantee since headers are copied to a byte buffer synchronously regardless. Worth considering removing it entirely. @rickdotnet wdyt?

The read-only flag was set on NatsHeaders before passing to publish to
catch accidental mutation after publish. It was a weak guarantee since
headers are copied to a byte buffer synchronously inside CommandWriter,
so post-publish mutation cannot affect wire data anyway.

Removing it lets serializers implementing INatsSerializeWithContext<T>
mutate headers (e.g. set Content-Type) during serialization, which was
otherwise blocked by ThrowIfReadOnly. Telemetry's SetOverrideReadOnly
escape hatch is no longer needed and uses the normal indexer instead.
@mtmk

mtmk commented Apr 7, 2026

Copy link
Copy Markdown
Member Author

Went ahead and removed the read-only flag entirely in 3a54d03 rather than just moving SetReadOnly(), since it was protecting against mutations that can't reach the wire anyway (headers get copied to a byte buffer synchronously inside CommandWriter). Added a test in SerializerTest.cs to verify serializers can mutate context.Headers (e.g. set Content-Type) during serialization. @rickdotnet please yell if you see any issue with this.

@mtmk mtmk changed the title Add optional NATS headers support to serialization interfaces Add message context to serialization interfaces Apr 7, 2026
mtmk added 6 commits April 10, 2026 09:43
Add net10.0 target to CoreUnit, Core, and Core2 test projects.
Add CheckAbiOld control project that runs against the old NuGet
version to prove type forwarding works by contrasting assembly
versions. Centralize the old NuGet version in Directory.Build.props
and make the run script assert loaded assembly versions.
Condition System.Net.Http and System.Text.RegularExpressions
package references to net481 only, where they are actually needed.
On net10.0 these are built-in and NuGet's package pruning treats
the unnecessary references as errors.
- NatsMsgContext: require Subject via constructor; drop init setters so
  netstandard shim for IsExternalInit is no longer needed.
- Add INatsSerializerWithContext<T> as a combined interface mirroring
  INatsSerializer<T>, simplifying class declarations for context-aware
  serializer+deserializer pairs.
- Built-in NatsUtf8PrimitivesSerializer, NatsRawSerializer and
  NatsJsonContextSerializer now implement INatsSerializerWithContext<T>
  and propagate the context through _next when delegating. Without this,
  chaining a built-in with a context-aware leaf silently dropped context
  at the leaf.
- Document NatsHeaders thread-safety: not safe to share across concurrent
  publishes, since readonly-locking was removed.
- Document that header-mutating serializers must get non-null headers
  from the caller; the library does not allocate on their behalf.
- Add chain-propagation tests for serialize and deserialize paths.
@mtmk
mtmk merged commit 710bbef into release/3.0 Apr 16, 2026
27 of 28 checks passed
@mtmk
mtmk deleted the serializer-with-headers branch April 16, 2026 13:54
@mtmk mtmk mentioned this pull request Apr 17, 2026
@mtmk mtmk added this to the Release 3.0 milestone May 26, 2026
mtmk added a commit that referenced this pull request May 27, 2026
* Add .NET 10 Target

* Release 3.0.0-preview.1 (#1071)

- Initial release with .NET 10 target
- Also dropped .NET 6 target

* Clean up NET6 and optimize NETSTANDARD (#1072)

* Clean up NET6 and optimize NETSTANDARD

- Remove .NET 5/6/7 from `#if` directives and build
- Delete commented-out file
- Optimize NETSTANDARD encoding polyfills used in hot paths
  to eliminate intermediate array allocations
- Fix `PeriodicTimer` CancellationTokenRegistration leak
- Add encoding polyfill benchmark

* Fix `PeriodicTimer` cancellation to prevent leak

- Ensure static lambda used to avoid allocation.

* Update DI package dependencies and documentation (#1075)

Switch project dependency from `NATS.Net` to `NATS.Client.Simplified` for better modularity. Add `NATS.Extensions.Microsoft.DependencyInjection` to `NATS.Net` dependencies. Update documentation to include comparisons between `NATS.Extensions.Microsoft.DependencyInjection` and `NATS.Client.Hosting`, clarifying usage scenarios. Add new DI guide to advanced docs.

* Release 3.0.0-preview.2 (#1091)

Merged main

* Add message context to serialization interfaces (#1082)

* Add support for optional NATS headers in serialization/deserialization

* Make `Serialize` method header-aware and add unit tests for header serialization logic

* Add recursion guard to prevent infinite loops in default interface methods for `Serialize`/`Deserialize`, update tests and examples accordingly

* Fix `Content-Type` header deserialization by updating to use `StringValues`

* Fix NuidTests flap

* Improve NuidTests stability

* Remove old default methods

* Remove `IDictionary` implementation from `NatsHeaders`

* Add INatsSerializeWithHeaders

* serializer: remove unnecessary obsolete warning pragmas

The CS0618 suppressions were leftover from the DIM approach
where interface members were marked obsolete. No longer needed
with the separate interface design.

* tests: add serialization extension and ABI checks

Add unit tests for NatsSerializationExtensions covering both
the header-aware path and the fallback path for serialize and
deserialize. Make the ABI check program assert correctness
instead of just printing, including the new WithHeaders
interfaces.

* serializer: replace WithHeaders with WithContext interfaces

Replace INatsSerializeWithHeaders<T>/INatsDeserializeWithHeaders<T>
with INatsSerializeWithContext<T>/INatsDeserializeWithContext<T>,
passing a NatsMsgContext struct that carries Subject, ReplyTo, and
Headers.

This gives serializers access to the full message envelope, enabling
subject-based dispatch and reply-to inspection without requiring
further interface changes in the future.

Also fixes the null-headers asymmetry in the extension methods:
both Serialize and Deserialize now always dispatch to the context
overload when the interface is implemented, regardless of whether
Headers is null.

* Revert "Improve NuidTests stability"

This reverts commit 42b8cbd.

* Revert "Fix NuidTests flap"

This reverts commit dcc3588.

* docs: fix stale DIM references and update serialization docs

Remove incorrect DIM claims from serialization docs, rewrite the
headers section to describe the WithContext interface design. Clean
up NativeAot example that had leftover INatsHeaders parameter
overloads from the old DIM approach. Restore dropped <returns>
doc comment on INatsDeserialize<T>.Deserialize.

* headers: drop read-only state

The read-only flag was set on NatsHeaders before passing to publish to
catch accidental mutation after publish. It was a weak guarantee since
headers are copied to a byte buffer synchronously inside CommandWriter,
so post-publish mutation cannot affect wire data anyway.

Removing it lets serializers implementing INatsSerializeWithContext<T>
mutate headers (e.g. set Content-Type) during serialization, which was
otherwise blocked by ThrowIfReadOnly. Telemetry's SetOverrideReadOnly
escape hatch is no longer needed and uses the normal indexer instead.

* tests: add net10.0 TFM and improve ABI checks

Add net10.0 target to CoreUnit, Core, and Core2 test projects.
Add CheckAbiOld control project that runs against the old NuGet
version to prove type forwarding works by contrasting assembly
versions. Centralize the old NuGet version in Directory.Build.props
and make the run script assert loaded assembly versions.

* tests: fix NU1510 package pruning errors on net10.0

Condition System.Net.Http and System.Text.RegularExpressions
package references to net481 only, where they are actually needed.
On net10.0 these are built-in and NuGet's package pruning treats
the unnecessary references as errors.

* serializers: address context review feedback

- NatsMsgContext: require Subject via constructor; drop init setters so
  netstandard shim for IsExternalInit is no longer needed.
- Add INatsSerializerWithContext<T> as a combined interface mirroring
  INatsSerializer<T>, simplifying class declarations for context-aware
  serializer+deserializer pairs.
- Built-in NatsUtf8PrimitivesSerializer, NatsRawSerializer and
  NatsJsonContextSerializer now implement INatsSerializerWithContext<T>
  and propagate the context through _next when delegating. Without this,
  chaining a built-in with a context-aware leaf silently dropped context
  at the leaf.
- Document NatsHeaders thread-safety: not safe to share across concurrent
  publishes, since readonly-locking was removed.
- Document that header-mutating serializers must get non-null headers
  from the caller; the library does not allocate on their behalf.
- Add chain-propagation tests for serialize and deserialize paths.

* sln: remove x64/x86 platform noise

* Release 3.0.0-preview.3 (#1113)

* Use System.Threading.Lock on NET9_0_OR_GREATER (#1118)

* core: use System.Threading.Lock on NET9_0_OR_GREATER

* core: fix reentrant lock in PublishToClientHandlersAsync

* bench: add lock and publish runtime comparison benchmarks

* Release 3.0.0-preview.4 (#1123)

* Release 3.0.0-preview.5 (#1130)

* Release 3.0.0-preview.6 (#1141)

* di: clarify package descriptions for NuGet [no ci]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants