This issue is made possible thanks to JetBrains, who help keep this newsletter free for everyone. A huge shout-out to them for their support of our community. Let's thank them by entering the link below. Struggling with slow builds, tricky bugs, or hard-to-understand performance issues? dotUltimate fixes all of that. Itâs the all-in-one toolbox for serious .NET developers. đ Upgrade your .NET workflow.
Seriously - have you?
Most .NET developers havenât, and thatâs a shame because NATS solves a completely different category of messaging problems than the tools we usually reach for.
When we think âmessaging,â the same names pop up:
Theyâre all great, and they all have their place...
But theyâre not the whole story.
Thereâs an entire class of systems where those brokers either become too slow, too heavy, or too complex.
And thatâs exactly where NATS appears as a surprisingly simple answer.
While traditional brokers focus on features like durability, transactions, partitions, advanced routing, and so onâŠ
NATS focuses on something else entirely:
đ Works beautifully with distributed edge devices
If your system needs:
âŠthen NATS suddenly becomes very interesting.
Let me give you a scenario where NATS actually makes perfect sense.
Imagine a modern agriculture system spread across a huge field.
You have:
These sensors send data every few hundred milliseconds.
Some devices drop offline. Some reconnect. Some flood the system with bursts of readings. Some need instructions back immediately (âturn on irrigation nowâ).
Itâs not a big âenterprise event busâ job.
Itâs a high-frequency, real-time, lightweight messaging challenge.
And this is exactly the kind of system where RabbitMQ or Kafka start to feel too heavy and too slow.
NATS is built for situations like this:
If your message volume is small and you want durability or transactions, use RabbitMQ or Service Bus.
If you want big data analytics or huge event streams, Kafka is the king.
But if you need:
âŠthatâs NATS territory.
Hereâs a realistic example using NATS for that smart agriculture system.
Weâll build:
First, install the official .NET client:
dotnet add package NATS.Client.Core
Whatâs happening here?
Youâll use NatsConnection from this package to publish and subscribe to messages.
You generally want one connection per service, reused everywhere (like HttpClient)
public static class NatsConnectionFactory{ public static NatsConnection Create() { return new NatsConnection(new NatsOpts { Url = "nats://localhost:4222", Name = "smart-agriculture", Reconnect = true, MaxReconnect = 10, ReconnectWait = TimeSpan.FromSeconds(1), }); }}
Explanation
Letâs define simple message types for:
public record SoilMoistureReading( string SensorId, string FieldId, double MoisturePercent, DateTime TimestampUtc);public record IrrigationCommand( string FieldId, bool EnableIrrigation, double TargetMoisturePercent, DateTime TimestampUtc);
Explanation
Records are convenient because:
Now, letâs simulate a sensor sending readings every 500ms.
public sealed class SoilSensorSimulator{ private NatsConnection _connection; private readonly string _sensorId; private readonly string _fieldId; private readonly Random _random = new(); public SoilSensorSimulator(NatsConnection connection, string sensorId, string fieldId) { _connection = connection; _sensorId = sensorId; _fieldId = fieldId; }Â public async Task RunAsync(CancellationToken ct) { while (!ct.IsCancellationRequested) { var reading = new SoilMoistureReading( SensorId: _sensorId, FieldId: _fieldId, MoisturePercent: 15 + _random.NextDouble() * 20, // 15â35% TimestampUtc: DateTime.UtcNow); var subject = $"sensors.soil.moisture.{_fieldId}"; await _connection.PublishAsync(subject, reading, cancellationToken: ct); Console.WriteLine($"[Sensor {_sensorId}] {reading.MoisturePercent:F1}% in field {reading.FieldId}"); await Task.Delay(500, ct); } }}
Explanation
subject is like a topic. Here we use a structured pattern: sensors.soil.moisture.{fieldId}. This makes it easy to subscribe to:
or youâd have multiple instances for multiple sensors.
Now letâs build a consumer that listens to all soil moisture sensors and logs them.
using Microsoft.Extensions.Hosting;using NATS.Client.Core;public sealed class SoilAnalyticsWorker : BackgroundService{ private NatsConnection _connection; public SoilAnalyticsWorker(NatsConnection connection) { _connection = connection; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Subscribe to all soil moisture readings from all fields await foreach (var msg in _connection.SubscribeAsync<SoilMoistureReading>( subject: "sensors.soil.moisture.*", cancellationToken: stoppingToken)) { var reading = msg.Data; Console.WriteLine( $"[Analytics] Field={reading.FieldId}, Sensor={reading.SensorId}, Moisture={reading.MoisturePercent:F1}%"); // Here you could: // - update an in-memory average // - push to a time-series DB // - feed a dashboard, etc. } }}
Explanation
SubscribeAsync
What if we have multiple instances of an irrigation controller service?
We want only one instance to handle each reading and decide on irrigation, not all of them.
Thatâs exactly what NATS queue groups do.
public sealed class IrrigationControllerWorker : BackgroundService{ private NatsConnection _connection; private const string QueueGroupName = "irrigation-controllers"; public IrrigationControllerWorker(NatsConnection connection) { _connection = connection; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var msg in _connection.SubscribeAsync<SoilMoistureReading>( subject: "sensors.soil.moisture.*", queueGroup: QueueGroupName, cancellationToken: stoppingToken)) { var reading = msg.Data; Console.WriteLine( $"[Controller] Field={reading.FieldId}, Moisture={reading.MoisturePercent:F1}%"); if (reading.MoisturePercent < 20) { var command = new IrrigationCommand( FieldId: reading.FieldId, EnableIrrigation: true, TargetMoisturePercent: 25, TimestampUtc: DateTime.UtcNow);Â await _connection.PublishAsync( "irrigation.commands", command, stoppingToken); Console.WriteLine( $"[Controller] Issued irrigation command for field {reading.FieldId}"); } } }}
Explanation
If you run 3 instances of this worker:
This is perfect for scaling âdecisionâ services horizontally.
Now we simulate a device that receives irrigation commands and acts on them.
public sealed class IrrigationDeviceWorker : BackgroundService{ private NatsConnection _connection; private readonly string _deviceId; public IrrigationDeviceWorker(NatsConnection connection, string deviceId) { _connection = connection; _deviceId = deviceId; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var msg in _connection.SubscribeAsync<IrrigationCommand>( subject: "irrigation.commands", cancellationToken: stoppingToken)) { var command = msg.Data; Console.WriteLine( $"[Device {_deviceId}] Apply irrigation â Field={command.FieldId}, Enable={command.EnableIrrigation}, Target={command.TargetMoisturePercent}%"); // Here you'd talk to actual hardware: // - open valve // - configure timer // - log to local storage, etc. } }}
Explanation
In reality, you might:
The core idea: the controller and device are loosely coupled via NATS.
Sometimes you donât want just a stream - you want to ask the system something:
âWhatâs the current moisture in Field-42?â
NATS supports this pattern natively with requestâreply.
8.1. Define request/response models and cache
public record FieldStatusRequest(string FieldId);Â public record FieldStatusResponse( string FieldId, double AverageMoisturePercent, DateTime LastUpdatedUtc);Â public interface IFieldStatusCache { (double AverageMoisture, DateTime LastUpdatedUtc) GetStatusForField(string fieldId); }
Explanation
public sealed class FieldStatusResponder : BackgroundService{ private NatsConnection _connection; private IFieldStatusCache _cache; public FieldStatusResponder(NatsConnection connection, IFieldStatusCache cache) { _connection = connection; _cache = cache; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var msg in _connection.SubscribeAsync<FieldStatusRequest>( subject: "fields.status.get", cancellationToken: stoppingToken)) { var (average, lastUpdated) = _cache.GetStatusForField(msg.Data.FieldId); var response = new FieldStatusResponse( FieldId: msg.Data.FieldId, AverageMoisturePercent: average, LastUpdatedUtc: lastUpdated);Â await msg.ReplyAsync(response, stoppingToken); } }}
Explanation
For each incoming request:
NATS handles the internal reply subject and correlation.
8.3. The client (e.g., your ASP.NET Core endpoint)
public sealed class FieldStatusClient{ private NatsConnection _connection;Â public FieldStatusClient(NatsConnection connection) { _connection = connection; }Â public async Task<FieldStatusResponse?> GetStatusAsync(string fieldId, CancellationToken ct = default) { var request = new FieldStatusRequest(fieldId); var msg = await _connection.RequestAsync<FieldStatusRequest, FieldStatusResponse>( subject: "fields.status.get", data: request, cancellationToken: ct);Â return msg.Data; }}
Explanation
Under the hood:
This pattern is very handy for internal RPC between services without needing gRPC/HTTP. You could easily expose this in ASP.NET Core:
app.MapGet("/fields/{fieldId}/status", async (string fieldId, FieldStatusClient client, CancellationToken ct) =>{ var status = await client.GetStatusAsync(fieldId, ct); return status is null ? Results.NotFound() : Results.Ok(status);});
You donât have to replace RabbitMQ or Kafka everywhere. Use the right tool for the right job.
NATS is a good fit when:
NATS is not ideal when:
Most developers never think about NATS simply because it doesnât dominate headlines the way Kafka or RabbitMQ do.
But once you see what kinds of problems it solves, it becomes one of those tools youâre genuinely happy to have discovered.
NATS doesnât try to be everything. It doesnât come with a huge learning curve. It doesnât require a cluster of heavyweight brokers.
It just gives you:
If your next project involves devices, sensors, real-time updates, automation, or anything where speed and simplicity matter more than heavy enterprise features, give NATS a try.
It might surprise you how far you can go with something this small and this fast.
For a production-ready example of consuming NATS at high speed and broadcasting via SignalR with batch flushing, see High-Throughput Real-Time Data with BoundedChannel and SignalR. For other messaging approaches, check out RabbitMQ in .NET from Scratch and Messaging in .NET with Redis.
That's all from me for today.
Stop arguing about code style. In this course you get a production-proven setup with analyzers, CI quality gates, and architecture tests â the exact system I use in real projects. Join here.
Not sure yet? Grab the free Starter Kit â a drop-in setup with the essentials from Module 01.
Design Patterns that Deliver â Solve real problems with 5 battle-tested patterns (Builder, Decorator, Strategy, Adapter, Mediator) using practical, real-world examples. Trusted by 650+ developers.
Just getting started? Design Patterns Simplified covers 10 essential patterns in a beginner-friendly, 30-page guide for just $9.95.
Every Monday morning, I share 1 actionable tip on C#, .NET & Architecture that you can use right away. Join here.
Join 20,000+ subscribers who mass-improve their .NET skills with actionable tips on C#, Software Architecture & Best Practices.
Subscribe to the TheCodeMan.net and be among the 20,000+ subscribers gaining practical tips and resources to enhance your .NET expertise.