NATS in .NET with MessageValidation.NatsNet

A message broker that fits in a ~20 MB binary, starts in milliseconds, and speaks a plain-text protocol over TCP. Most .NET teams have never tried it.

It's NATS โ€” and the official .NET client, NATS.Net, is one of the cleanest messaging APIs in the ecosystem.

๐Ÿ“ฆ NuGet: MessageValidation.NatsNet ยท GitHub ยท MIT ยท .NET 10


The Whole Mental Model in One Paragraph

No exchanges. No partitions. No consumer group rebalancing. You publish to a subject, you subscribe to a subject. That's it.

Subjects are hierarchical (orders.placed, sensors.kitchen.temperature) and wildcards come free:

Pattern Matches
orders.placed Exact match only
sensors.*.temperature sensors.kitchen.temperature, sensors.bedroom.temperature, โ€ฆ
orders.> Everything under orders. โ€” any depth

Getting Started with NATS.Net

docker run -p 4222:4222 nats:latest
dotnet add package NATS.Net

The client is fully async and built on IAsyncEnumerable:

await using var client = new NatsClient("nats://localhost:4222");

// Publish
await client.PublishAsync("orders.placed",
    new OrderPlaced("ORD-1001", "alice@example.com", 42.50m));

// Subscribe โ€” wildcards built in
await foreach (var msg in client.SubscribeAsync<OrderPlaced>("orders.>"))
{
    Console.WriteLine($"[{msg.Subject}] {msg.Data!.OrderId}");
}

Three lines to connect and subscribe. No channel setup, no topology declaration, no admin API.

Need persistence and replay? JetStream is built into the same server โ€” opt in per stream when you need it, ignore it when you don't.

Queue Groups: Scaling Without Configuration

Scaling consumers in most brokers means configuration: partitions, prefetch counts, consumer groups. In NATS it means starting another process.

Subscribers that share a queue group name split the messages โ€” NATS picks one member per message:

await foreach (var msg in client.SubscribeAsync<OrderPlaced>(
    "orders.>", queueGroup: "order-workers"))
{
    // Only this instance receives this particular message
}

Add an instance, it gets work. Kill an instance, the others absorb it. No partition counts to size upfront, no rebalancing storms.

NATS vs RabbitMQ: Where Does Routing Live?

In RabbitMQ, routing is broker-side configuration: exchanges, bindings, queues. Powerful and explicit โ€” and one more thing to version, migrate, and debug.

In NATS, routing is the subject name. Nothing to declare, nothing to drift.

Where RabbitMQ wins:

  • Per-message acknowledgements, requeue and TTL semantics out of the box
  • Mature management UI and plugin ecosystem
  • Complex routing: headers, priorities, delayed exchanges

Where NATS wins:

  • Latency โ€” plain-text protocol, no broker-side routing tables
  • Ops โ€” a single ~20 MB binary, clustering built in
  • Simplicity โ€” queue groups give you competing consumers with zero configuration

NATS vs Kafka: Do You Need a Log or a Bus?

Kafka is a distributed commit log. NATS is a messaging fabric with an optional log (JetStream). Different tools, different defaults.

Where Kafka wins:

  • Massive sustained throughput with strict partition ordering
  • Long-term retention as a first-class design goal
  • The stream-processing ecosystem: Connect, Streams, ksqlDB

Where NATS wins:

  • Request-reply and fan-out with sub-millisecond latency
  • Consumer scaling without partition math
  • JetStream gives you persistence and replay when you opt in โ€” per stream, not as a lifestyle

Rule of thumb: if your events are the source of truth, Kafka's log model earns its ops cost. If messages are commands and notifications in flight, NATS is dramatically less machine.

Validating Every NATS Message in One Line

Whichever broker you choose, the bytes that arrive still need to be deserialized and validated before your business logic touches them. Every NATS consumer I've seen does the same dance: deserialize, null-check, validate, then finally the actual work.

That's the layer MessageValidation owns โ€” and MessageValidation.NatsNet is its NATS adapter, joining the existing MQTT, RabbitMQ, Kafka, Azure Service Bus, and Event Hubs transports.

dotnet add package MessageValidation.NatsNet

Register the pipeline โ€” the same AddMessageValidation(...) call as every other transport:

builder.Services.AddMessageValidation(options =>
{
    options.MapSource<TemperatureReading>("sensors.*.temperature");
    options.DefaultFailureBehavior = FailureBehavior.DeadLetter;
});

builder.Services.AddMessageDeserializer<JsonMessageDeserializer>();
builder.Services.AddScoped<IMessageValidator<TemperatureReading>, TemperatureReadingValidator>();
builder.Services.AddMessageHandler<TemperatureReading, TemperatureHandler>();

// Registers a singleton INatsConnection
builder.Services.AddNatsNetMessageValidation("nats://localhost:4222");

Then one line hooks the pipeline onto any subscription:

public class NatsWorker(
    INatsConnection connection,
    IMessageValidationPipeline pipeline) : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken ct) =>
        connection.SubscribeWithMessageValidationAsync(
            subject: "sensors.*.temperature",
            pipeline: pipeline,
            ct: ct);
}

Every message on that subject is now deserialized, validated, and dispatched to a typed handler โ€” or dead-lettered with full error context if it fails. Your handler only ever sees valid messages.

NATS Metadata in Your Handlers

The adapter preserves NATS specifics in the message metadata:

Key Description
nats.subject The subject the message was delivered on
nats.replyTo The reply-to subject, if any
nats.headers Message headers, if any
nats.queueGroup Queue group name, if subscribed with one
public class TemperatureHandler : IMessageHandler<TemperatureReading>
{
    public Task HandleAsync(
        TemperatureReading message, MessageContext context, CancellationToken ct = default)
    {
        var subject = context.Metadata["nats.subject"];
        Console.WriteLine($"[{subject}] Sensor {message.SensorId}: {message.Value}°C");
        return Task.CompletedTask;
    }
}

Queue Groups + Validation

The queue group is one argument on the subscription โ€” and every load-balanced message still flows through the full pipeline:

await connection.SubscribeWithMessageValidationAsync(
    subject: "orders.>",
    pipeline: pipeline,
    queueGroup: "order-workers",   // โ† the whole consumer-group story
    ct: ct);

Run three replicas of the same worker and you get horizontally-scaled, validated consumption with zero broker configuration. Invalid messages don't vanish into a log line either โ€” the dead-letter handler receives the destination, the raw payload, and every validation error.

Takeaway

Pick the broker for its ops profile and your data model โ€” not for your consumer code. With a broker-agnostic validation pipeline, your validators, handlers, and dead-letter logic don't move when the broker does.

If your messaging needs are "send this from A to B, fast", NATS deserves 30 minutes of your time.