Skip to content

The search box in the website knows all the secrets—try it!

For any queries, join our Discord Channel to reach us faster.

JasperFx Logo

JasperFx provides formal support for Wolverine and other JasperFx libraries. Please check our Support Plans for more details.

Using NATS ​

TIP

Wolverine uses the official NATS.Net client to connect to NATS.

Installing ​

To use NATS as a messaging transport with Wolverine, first install the WolverineFx.Nats library via NuGet:

bash
dotnet add package WolverineFx.Nats

Core NATS vs JetStream ​

NATS provides two distinct messaging models:

FeatureCore NATSJetStream
PersistenceNone (memory only)Configurable (memory/file)
Delivery GuaranteeAt-most-onceAt-least-once
AcknowledgmentsNoneFull support (ack/nak/term)
RequeueVia republishNative via NakAsync()
Dead LetterNot availableVia AckTerminateAsync()
Scheduled DeliveryNot availableNative (Server 2.12+)

Choose Core NATS for:

  • Real-time notifications where message loss is acceptable
  • Low-latency fire-and-forget messaging
  • Heartbeats and ephemeral events

Choose JetStream for:

  • Commands and events requiring durability
  • Workflows where message delivery must be guaranteed
  • Scenarios requiring replay or scheduled delivery

Basic Configuration ​

Core NATS (Simple Pub/Sub) ​

csharp
using var host = await Host.CreateDefaultBuilder()
    .UseWolverine(opts =>
    {
        // Connect to NATS
        opts.UseNats("nats://localhost:4222")
            .AutoProvision();

        // Listen to a subject
        opts.ListenToNatsSubject("orders.received")
            .ProcessInline();

        // Publish to a subject
        opts.PublishAllMessages()
            .ToNatsSubject("orders.received");
    }).StartAsync();

JetStream (Durable Messaging) ​

csharp
using var host = await Host.CreateDefaultBuilder()
    .UseWolverine(opts =>
    {
        opts.UseNats("nats://localhost:4222")
            .AutoProvision()
            .UseJetStream(js => { })
            .DefineWorkQueueStream("ORDERS", "orders.>");

        // Listen with JetStream consumer
        opts.ListenToNatsSubject("orders.received")
            .UseJetStream("ORDERS", "orders-consumer");

        // Publishing automatically uses JetStream when stream is defined
        opts.PublishAllMessages()
            .ToNatsSubject("orders.received");
    }).StartAsync();

Aspire Integration ​

The recommended way to integrate Wolverine with .NET Aspire for NATS is to read the connection string injected by Aspire via IConfiguration.GetConnectionString(). Aspire injects the NATS URL when you use .WithReference() in the AppHost.

AppHost (Aspire.Hosting.Nats NuGet):

csharp
var nats = builder.AddNATS("nats")
    .WithJetStream();

builder.AddProject<Projects.MyWorker>("worker")
    .WithReference(nats)
    .WaitFor(nats);

Service project:

csharp
var builder = Host.CreateApplicationBuilder(args);

// Aspire injects ConnectionStrings__nats as a nats:// URL automatically via WithReference()
var natsUrl = builder.Configuration.GetConnectionString("nats")
    ?? "nats://localhost:4222";

builder.UseWolverine(opts =>
{
    opts.UseNats(natsUrl)
        .AutoProvision();

    opts.ListenToNatsSubject("orders").UseJetStream("ORDERS", "orders-consumer");
    opts.PublishMessage<OrderPlaced>().ToNatsSubject("orders");
});

await builder.Build().RunAsync();

WaitFor(nats) in the AppHost ensures NATS is healthy before your service starts, making AutoProvision() reliable.

Connection Configuration ​

Basic Connection ​

csharp
opts.UseNats("nats://localhost:4222");

Connection with Timeouts ​

csharp
opts.UseNats("nats://localhost:4222")
    .ConfigureTimeouts(
        connectTimeout: TimeSpan.FromSeconds(10),
        requestTimeout: TimeSpan.FromSeconds(30)
    );

Customizing the NATS Client Options 6.47 ​

For NATS.Net client settings the transport does not surface itself, ConfigureNatsOpts() hands you the NatsOpts Wolverine built for a connection and opens the connection with whatever you return. It runs last, after Wolverine has applied its own configuration and named the connection, and it applies to the shared connection and to every tenant's dedicated connection:

csharp
opts.UseNats("nats://localhost:4222")
    .ConfigureNatsOpts(o => o with
    {
        // Core NATS subscriptions buffer up to SubPendingChannelCapacity messages per subscription
        // and, by default, drop the newest ones once that buffer is full
        SubPendingChannelCapacity = 4096,

        // Notice a dead connection sooner than the client default of 2 minutes x 2 missed pings
        PingInterval = TimeSpan.FromSeconds(10)
    });

Without the hook the NATS.Net defaults apply unchanged: a pending channel of 1,024 messages per subscription that drops the newest message when full (BoundedChannelFullMode.DropNewest), and a ping every two minutes with two outstanding pings allowed. Think twice before switching SubPendingChannelFullMode to Wait: a full channel then stalls the connection's read loop instead of dropping, so one slow core NATS listener holds up every subscription on that connection, and the server eventually disconnects the client as a slow consumer.

A tenant added with its own connection configuration can set ConfigureNatsOpts on that configuration, which replaces the transport's hook for that tenant's connection:

csharp
opts.UseNats("nats://shared:4222")
    .AddTenant("tenant-a", cfg =>
    {
        cfg.ConnectionString = "nats://tenant-a-host:4222";
        cfg.ConfigureNatsOpts = o => o with { SubPendingChannelCapacity = 10_000 };
    });

Dropped Core NATS Messages 6.47 ​

A core NATS subscription buffers incoming messages in a pending channel of 1,024 messages, and NATS.Net drops the newest message once that channel is full. Core NATS never redelivers it. Wolverine logs a warning from NatsTransport for every dropped message (subject, subscription, connection) and one for each slow-consumer episode of a subscription, on the shared and on the tenant connections. If you see them, raise SubPendingChannelCapacity through ConfigureNatsOpts(), scale out the listener, or move the subject to JetStream.

Authentication ​

Username and Password ​

csharp
opts.UseNats("nats://localhost:4222")
    .WithCredentials("username", "password");

Token Authentication ​

csharp
opts.UseNats("nats://localhost:4222")
    .WithToken("my-secret-token");

NKey Authentication ​

csharp
opts.UseNats("nats://localhost:4222")
    .WithNKey("/path/to/nkey.file");

TLS Configuration ​

csharp
opts.UseNats("nats://localhost:4222")
    .UseTls(insecureSkipVerify: false);

JetStream Configuration ​

Configuring JetStream Defaults ​

csharp
opts.UseNats("nats://localhost:4222")
    .UseJetStream(js =>
    {
        js.MaxDeliver = 5;           // Max redelivery attempts
        js.AckWait = TimeSpan.FromSeconds(30);
        js.DuplicateWindow = TimeSpan.FromMinutes(2);
    });

MaxDeliver, AckWait and DeliverPolicy apply to the consumers of Wolverine's JetStream listeners. The stream limits in JetStreamDefaults (MaxAge, MaxMessages, MaxBytes, Replicas) only apply to a stream that resources setup / AddResourceSetupOnStartup() creates because a JetStream endpoint's stream does not exist yet. A stream you declare with DefineStream() and friends does not inherit them — whatever its declaration leaves unset is unlimited — and only DuplicateWindow serves as its default.

Consumer Deliver Policy ​

When Wolverine auto-provisions a JetStream consumer for a listener it leaves the consumer config's DeliverPolicy unset, which falls through to NATS's own default of DeliverPolicy.All — every message currently in the stream is replayed when the consumer first connects. For new listeners attached to a long-running stream that's usually not what you want.

Set a transport-wide default through JetStreamDefaults.DeliverPolicy so every auto-provisioned consumer under this transport starts at the same position:

csharp
opts.UseNats("nats://localhost:4222")
    .UseJetStream(js =>
    {
        js.DeliverPolicy = ConsumerConfigDeliverPolicy.New; // only messages
                                                            // from now on
    });

Override per-listener with DeliverFrom(...) when a single endpoint needs a different position:

csharp
opts.ListenToNatsSubject("orders.received")
    .UseJetStream("ORDERS")
    .DeliverFrom(ConsumerConfigDeliverPolicy.New);

The per-listener override always wins over the transport-wide default. When neither is set Wolverine writes nothing to the consumer config and the NATS server default (All) applies.

The override only applies to consumers Wolverine itself auto-provisions. If you reference a pre-created consumer by name with UseJetStream(streamName, consumerName), Wolverine keeps that consumer's existing DeliverPolicy regardless of DeliverFrom(...) — JetStream does not allow changing it on an existing consumer, so pre-creating the consumer with the desired policy via the NATS CLI or JetStream API is the right tool there.

ConsumerConfigDeliverPolicyEffect
AllReplay every message currently in the stream (NATS-server default).
NewOnly deliver messages that arrive after the consumer is created.
LastDeliver only the latest message in the stream.
LastPerSubjectDeliver the latest message per matching subject filter.
ByStartSequence / ByStartTimeStart from a specific sequence number or timestamp. Requires pre-creating the consumer outside Wolverine — OptStartSeq / OptStartTime have no listener-configuration surface.

Defining Streams ​

Work Queue Stream (Retention by Interest) ​

csharp
opts.UseNats("nats://localhost:4222")
    .DefineWorkQueueStream("ORDERS", "orders.>");

Interest retention, not JetStream work-queue retention

Despite their names, DefineWorkQueueStream() and StreamConfiguration.AsWorkQueue() set StreamConfigRetention.Interest. An interest stream keeps a message only while a consumer is interested in it, so a message published while no consumer is bound — before the first listener starts, or between two deployments — is discarded on arrival. For JetStream's work-queue retention, which keeps every message until a consumer acknowledges it, set the retention explicitly:

csharp
opts.UseNats("nats://localhost:4222")
    .DefineStream("ORDERS", s =>
    {
        s.WithSubjects("orders.>");
        s.Retention = StreamConfigRetention.Workqueue;
    });

Work Queue with Additional Configuration ​

csharp
opts.UseNats("nats://localhost:4222")
    .DefineWorkQueueStream("ORDERS", 
        stream => stream.EnableScheduledDelivery(), 
        "orders.>");

Custom Stream Configuration ​

csharp
opts.UseNats("nats://localhost:4222")
    .DefineStream("EVENTS", stream =>
    {
        stream.WithSubjects("events.>")
              .WithLimits(maxMessages: 1_000_000, maxAge: TimeSpan.FromDays(7))
              .WithReplicas(3)
              .EnableScheduledDelivery();
    });

Log Stream (Time-Based Retention) ​

csharp
opts.UseNats("nats://localhost:4222")
    .DefineLogStream("LOGS", TimeSpan.FromDays(30), "logs.>");

Replicated Stream (High Availability) ​

csharp
opts.UseNats("nats://localhost:4222")
    .DefineReplicatedStream("CRITICAL", replicas: 3, "critical.>");

JetStream Domain ​

For multi-tenant or leaf node configurations:

csharp
opts.UseNats("nats://localhost:4222")
    .UseJetStreamDomain("my-domain");

The domain applies to every JetStream call the transport makes: publishing, listening, auto-provisioning, and the stream and consumer setup done by resources setup / AddResourceSetupOnStartup()

6.47. Resource setup only creates a stream when the lookup reports it as missing;

any other failure, such as no JetStream answering for the domain, fails the setup instead.

Listening to Messages ​

Inline Processing ​

Messages are processed immediately on the NATS subscription thread:

csharp
opts.ListenToNatsSubject("orders.received")
    .ProcessInline();

Buffered Processing ​

Messages are queued in memory and processed by worker threads:

csharp
opts.ListenToNatsSubject("orders.received")
    .BufferedInMemory();

JetStream Consumer ​

csharp
opts.ListenToNatsSubject("orders.received")
    .UseJetStream("ORDERS", "my-consumer");

Native Acks with Parallel Processing 6.30 ​

JetStream listeners support EndpointMode.NativeAck (see GH-3708), which combines BufferedInMemory's parallelism and group partitioning with ProcessInline()'s no-loss behavior, and needs no database at all:

csharp
opts.ListenToNatsSubject("webhooks")
    .UseJetStream("WEBHOOKS", "webhook-processors")
    .ProcessInParallelWithNativeAcks()
    .PartitionProcessingByGroupId(PartitionSlots.Seven);

Incoming messages flow through an in-memory (optionally group-partitioned) execution block while the JetStream delivery is held unacknowledged, and are settled natively — Ack on handler success, Nak or AckTerminate on terminal failure — from the completion continuation. JetStream qualifies for this mode because each delivery is settled individually against the message's own reply subject, so one delivery can be settled, out of order, from whichever worker thread finished it.

The guarantee is the one the mode promises everywhere: messages sharing a group id never execute concurrently, and nothing is acknowledged until its handler succeeds, so a node that dies mid-flight has every unfinished message redelivered. Processing in original stream order is best effort, not promised — a failed or redelivered message re-enters its lane later, never concurrently. Use UseDurableInbox() if you need strict ordering under failure.

Core NATS cannot use this mode

ProcessInParallelWithNativeAcks() requires JetStream and is refused at bootstrap on a core NATS subject. Core NATS delivers a message once and forgets it — there is no unacknowledged delivery to hold and no redelivery when a node dies — so the crash-safety story the mode exists for does not exist there. Use BufferedInMemory() or ProcessInline() on core NATS.

AckWait is a real clock, and Wolverine renews it ​

Unlike RabbitMQ, where an unacknowledged delivery lives until the channel closes, JetStream redelivers anything still unacknowledged after the consumer's AckWait. Under native acks a message is held unsettled for lane queue time plus handler time, and lane queue time is unbounded by design — so Wolverine keeps the lease alive by sending JetStream's AckProgress for every queued-but-unsettled message, every half AckWait, for as long as it sits in a lane (GH-4048). You do not configure this; it is what the mode requires to be correct on this transport.

Each renewal is sent as a double ack (a request the server answers) rather than fire-and-forget, because that is the only way a lost lease — a deleted consumer, a dead connection — can be detected at all. If a lease is lost before a message starts executing, it is dropped from its lane without being settled, so the server's redelivery is the only remaining copy rather than a second one.

Two knobs, both optional:

csharp
opts.ListenToNatsSubject("webhooks")
    .UseJetStream("WEBHOOKS", "webhook-processors")
    .ProcessInParallelWithNativeAcks()

    // How long the server waits before redelivering an unacked message, and therefore how
    // often Wolverine renews. Default 30 seconds (JetStreamDefaults.AckWait).
    .AckWait(TimeSpan.FromSeconds(30))

    // Ceiling on how long ONE delivery may be kept alive by renewals, measured from receipt.
    // Past this Wolverine stops renewing, so a wedged handler cannot pin a MaxAckPending slot
    // forever. Default 12 hours.
    .MaximumAckExtension(TimeSpan.FromHours(1));

MaxAckPending is the prefetch, and it must cover every lane ​

MaxAckPending — the number of deliveries JetStream will leave unacknowledged before it stops delivering — is this transport's prefetch equivalent, and in this mode it is the back pressure. Wolverine defaults it to twice the number of lanes that can be busy at once: the PartitionProcessingByGroupId() slot count when the endpoint is partitioned, otherwise MaximumParallelMessages(). Every other endpoint mode leaves the NATS server default of 1,000 alone.

Sizing it below the lane count makes the consumer stall itself — the server stops delivering while lanes sit idle waiting for work. Override it only if you know why:

csharp
opts.ListenToNatsSubject("webhooks")
    .UseJetStream("WEBHOOKS", "webhook-processors")
    .ProcessInParallelWithNativeAcks()
    .MaxAckPending(64);

Don't call SendInline() on a subject you also listen to

A publisher and a listener for the same subject resolve to the same endpoint, and EndpointMode is a single property on it — so opts.PublishMessage<T>().ToNatsSubject("x").SendInline() sets Mode = Inline and silently downgrades a ProcessInParallelWithNativeAcks() listener on "x" out of native acks. It is also unnecessary: a native-ack endpoint already sends through the inline sending agent.

Server-side dedup is a different guarantee

JetStream's Nats-Msg-Id duplicate window (see JetStream Configuration) deduplicates on publish, at stream ingest. It does not deduplicate redeliveries — a redelivered message was already accepted into the stream, so the window never sees it again. Native acks are at-least-once by design and produce a burst of redeliveries on every rolling deploy, so if you want those suppressed, that is what WithInMemoryIdempotency() is for. The two are complementary, not alternatives.

Named Endpoints ​

csharp
opts.ListenToNatsSubject("orders.received")
    .Named("orders-listener");

Load Balancing with Queue Groups ​

Multiple listeners sharing a NATS queue group have each message delivered to only one member, spreading load across instances. Set a transport-wide default so every listener joins the same group:

csharp
opts.UseNats(nats =>
{
    nats.ConnectionString = "nats://localhost:4222";
    nats.DefaultQueueGroup = "orders-workers";
});

Subject normalization

By default the transport normalizes / separators in subjects to NATS . tokens (NormalizeSubjects, on by default). Set nats.NormalizeSubjects = false if you need to use literal subjects that contain /.

Publishing Messages ​

To a Specific Subject ​

csharp
opts.PublishMessage<OrderCreated>()
    .ToNatsSubject("orders.created");

All Messages to a Subject ​

csharp
opts.PublishAllMessages()
    .ToNatsSubject("events");

Inline Sending ​

Send messages synchronously without buffering:

csharp
opts.PublishAllMessages()
    .ToNatsSubject("orders")
    .SendInline();

Static Outgoing Headers ​

Attach a constant header to every message published to a subject with AddOutgoingHeader:

csharp
opts.PublishMessage<OrderCreated>()
    .ToNatsSubject("orders.created")
    .AddOutgoingHeader("x-source", "orders-service");

Per-Message (Dynamic) Subjects ​

ToNatsSubject("...") publishes to a single static subject. To compute the subject per message — e.g. an aggregate-scoped subject like orders.events.{id} — use PublishMessagesToNatsSubject<T>. This is built on Wolverine's generic topic routing (RoutingMode.ByTopic / Envelope.TopicName), the same mechanism the RabbitMQ, Kafka, and MQTT transports use, so it also participates in IMessageBus.BroadcastToTopicAsync.

csharp
opts.UseNats("nats://localhost:4222").AutoProvision();

// The subject is derived from each message instance.
opts.PublishMessagesToNatsSubject<OrderEvent>(e => $"orders.events.{e.OrderId}");

The same endpoint is automatically enrolled for explicit topic broadcasts, where the caller supplies the subject directly (overriding the function):

csharp
await bus.BroadcastToTopicAsync("orders.events.12345", new OrderShipped(...));

Consuming dynamic subjects

Because the publish subject varies, a consumer must subscribe to the whole space with a NATS wildcard. For Core NATS, listen on orders.events.>. For JetStream, provision the stream over a wildcard subject (orders.events.>) so it captures every computed subject, then listen with a matching consumer filter. A too-narrow stream subject silently fails to capture the dynamic subjects.

For subject shaping that a strongly-typed Func<T, string> can't express — for example deriving the subject from an envelope header or tenant id — configure an ISubjectResolver. It runs after the base/topic subject is determined and can rewrite it from any envelope state:

csharp
opts.UseNats(nats =>
{
    nats.ConnectionString = "nats://localhost:4222";
    nats.SubjectResolver = new MyAggregateSubjectResolver();
});

Deduplication (JetStream Nats-Msg-Id) ​

Wolverine stamps a Nats-Msg-Id on every JetStream publish, so the stream's duplicate window discards duplicates server-side — the idempotency key external (non-Wolverine) consumers can rely on, independent of Wolverine's own durable-inbox dedup on Envelope.Id.

By default the id is the Wolverine Envelope.Id. Project a domain identity instead with DeduplicateUsing so a logical event dedups even across separate sends (e.g. {stream}/{version}):

csharp
opts.UseNats("nats://localhost:4222")
    .AutoProvision()
    // Any two publishes resolving to the same key within the stream's duplicate window collapse to one.
    .DeduplicateUsing(envelope => $"{envelope.GroupId}/{envelope.Id}");

Precedence for the dedup key:

  1. An explicit Nats-Msg-Id header already on the outgoing envelope always wins.
  2. Otherwise the configured DeduplicateUsing function is used.
  3. Otherwise the Wolverine Envelope.Id.

The duplicate window itself is configured per stream (WithDeduplicationWindow) or transport-wide via JetStreamDefaults.DuplicateWindow (default two minutes):

csharp
opts.UseNats("nats://localhost:4222")
    .DefineStream("ORDERS", s => s
        .WithSubjects("orders.>")
        .WithDeduplicationWindow(TimeSpan.FromMinutes(5)));

Scheduled Message Delivery ​

NATS Server 2.12+ supports native scheduled message delivery. When enabled, Wolverine uses NATS headers for scheduling instead of database persistence.

Requirements ​

  1. NATS Server version >= 2.12
  2. Stream configured with EnableScheduledDelivery()

Configuration ​

csharp
opts.UseNats("nats://localhost:4222")
    .UseJetStream(js => { })
    .DefineWorkQueueStream("ORDERS", 
        s => s.EnableScheduledDelivery(), 
        "orders.>");

How It Works ​

When conditions are met, scheduled messages use NATS headers:

  • Nats-Schedule: @at <RFC3339 timestamp>
  • Nats-Schedule-Target: <destination subject>

The transport automatically detects server version at startup.

NATS requires the scheduling (control) message to be published to a subject that is different from Nats-Schedule-Target — publishing both to the same subject is rejected with message schedules target is invalid (err 10190). Wolverine therefore publishes the control message to a derived schedule subject — the destination subject plus a suffix (default .scheduled, e.g. orders.created.scheduled) — while Nats-Schedule-Target stays the real destination (orders.created). At the scheduled time the server materializes a new message onto the target subject, where your listener's consumer receives it; the control message itself is never delivered to consumers.

Both the target and the derived schedule subject must be covered by the same stream. The schedule subject is the target plus an extra suffix token (orders.created → orders.created.scheduled), so it always has one more token than the target. Any filter that only matches the target's token count — an exact-subject filter, or a * pattern such as orders.* — therefore covers the target but not<subject>.scheduled. Cover both with a >-style prefix wildcard such as orders.>, or list the target and schedule subjects as explicit filters. Override the suffix per publishing endpoint when needed:

csharp
opts.PublishMessage<OrderCreated>()
    .ToNatsSubject("orders.created")
    .UseJetStream("ORDERS")
    .UseScheduleSubjectSuffix(".deferred");

Fallback Behavior ​

When native scheduled send is not available (server < 2.12 or stream not configured), Wolverine falls back to its database-backed scheduled message persistence.

Connecting to Multiple NATS Brokers ​

If a single Wolverine application needs to talk to more than one NATS broker, register the additional broker(s) with AddNamedNatsBroker using a BrokerName, then pin publishing or listening to a specific broker with the *OnNamedBroker overloads:

csharp
opts.UseNats("nats://localhost:4222");

// An additional, independent NATS broker identified by name
opts.AddNamedNatsBroker(new BrokerName("secondary"), "nats://secondary-nats:4222");

// Or configure the additional broker with the full connection/auth surface
opts.AddNamedNatsBroker(new BrokerName("eu"), cfg =>
{
    cfg.ConnectionString = "nats://eu-nats:4222";
    cfg.EnableJetStream = true;
});

// Publish a message type to a subject on a named broker
opts.PublishMessage<OrderPlaced>()
    .ToNatsSubjectOnNamedBroker(new BrokerName("secondary"), "orders");

// Listen to a subject on a named broker
opts.ListenToNatsSubjectOnNamedBroker(new BrokerName("secondary"), "orders");

INFO

The Wolverine Uri scheme for any endpoint on a named broker is the broker name itself, so in the example above you would see endpoint URIs like secondary://subject/orders. The default broker keeps the canonical nats:// scheme, which keeps the two brokers' endpoints from colliding.

Connecting to multiple named brokers is distinct from Multi-Tenancy: a named broker is a statically-addressed second connection that you target explicitly, whereas per-tenant connections are selected at runtime from each message's tenant id.

Multi-Tenancy ​

TIP

For a holistic overview of multi-tenancy across all of Wolverine, see the Multi-Tenancy Tutorial and Multi-Tenancy with Wolverine for how Wolverine tracks the tenant id across messages.

The NATS transport supports two flavors of tenant isolation:

  • Subject-based — all tenants share one connection and are separated by a tenant subject prefix ({tenantId}.{subject}). This is soft partitioning within a single NATS account.
  • Connection-based — a tenant gets its own dedicated NATS connection to a different server or account.

NATS accounts are the native tenancy boundary

In NATS, true multi-tenancy is Accounts: each account is a fully isolated subject namespace, and a single connection authenticates into exactly one account. So a genuinely isolated tenant means a dedicated connection with its own credentials (see Per-Tenant Connections). A subject prefix on a shared connection is only partitioning within one account, not account-level isolation.

Basic Multi-Tenancy (Subject Isolation) ​

csharp
opts.UseNats("nats://localhost:4222")
    .ConfigureMultiTenancy(TenantedIdBehavior.TenantIdRequired)
    .AddTenant("tenant-a")
    .AddTenant("tenant-b");

Tenant Behavior Options ​

  • TenantIdRequired: Throws if tenant ID is missing
  • FallbackToDefault: Uses base subject if tenant ID is missing

Per-Tenant Connections ​

To route a tenant to its own NATS server or account, add it with a configuration action. The action receives a copy of the transport's own connection settings, so you only override what differs for this tenant — a different URL, or any of the NATS auth mechanisms (token, JWT/NKey, credentials file, client certificate):

csharp
opts.UseNats("nats://shared:4222")
    .ConfigureMultiTenancy(TenantedIdBehavior.FallbackToDefault)
    .AddTenant("tenant-a", cfg => cfg.ConnectionString = "nats://tenant-a-host:4222")
    .AddTenant("tenant-b", cfg =>
    {
        cfg.ConnectionString = "nats://tenant-b-host:4222";
        cfg.CredentialsFile = "/etc/nats/tenant-b.creds";
    });

Each tenant with its own configuration gets a dedicated connection, owned by the transport for its lifetime. Tenants added without a configuration action keep sharing the transport connection (subject-prefix isolation only).

Both sending and listening are tenant-aware. A listener consumes on the shared connection and on each tenant's dedicated connection: when a message arrives on a tenant connection it is stamped with that tenant's id, and its ack/nak/dead-letter is routed back over the same connection. Sending a message tagged with a TenantId publishes it over that tenant's connection. If any tenant streams need JetStream, the configured streams are auto-provisioned on each tenant server as well (when AutoProvision() is on).

Custom Subject Mapper ​

csharp
public class MyTenantMapper : ITenantSubjectMapper
{
    public string MapSubject(string baseSubject, string tenantId)
        => $"{tenantId}.{baseSubject}";
    
    public string? ExtractTenantId(string subject)
        => subject.Split('.').FirstOrDefault();
    
    public string GetSubscriptionPattern(string baseSubject)
        => $"*.{baseSubject}";
}

opts.UseNats("nats://localhost:4222")
    .UseTenantSubjectMapper(new MyTenantMapper());

Request-Reply ​

Wolverine's request-reply pattern works with NATS:

csharp
// Send and wait for response
var response = await bus.InvokeAsync<OrderConfirmation>(new CreateOrder(...));

The response endpoint always uses Core NATS for low-latency replies, even when the main endpoints use JetStream.

When a request goes to a core NATS subject that nothing subscribes to, the NATS server answers it with a "no responders" status, and InvokeAsync() fails at once with a WolverineRequestReplyException instead of waiting out its timeout 6.47. To tie that answer to the request, every request carries its own reply subject on the wire — the node's reply subject plus a per-request token, wolverine.response.{service}.{node}.{token} — and the reply listener also subscribes to wolverine.response.{service}.{node}.>. A Wolverine responder answers on exactly the reply subject the request carried, so it keeps working when its NATS user may only publish responses (allow_responses).

Error Handling ​

JetStream ​

  • Rejected publishes 6.47: a JetStream publish the server refuses — the stream is full under DiscardPolicy.New, a Nats-Expected-Last-Sequence (or other Nats-Expected-*) check fails, the message is larger than the stream allows — fails the send with a NatsJSApiException. A durable outbox keeps the message and retries it through the sending agent's circuit breaker instead of deleting it as sent. A publish the stream discards as a duplicate Nats-Msg-Id is still a successful send, because the stream already holds that message.
  • Retry: Message is requeued via NakAsync() with optional delay, up to the consumer's maximum delivery attempts (JetStreamDefaults.MaxDeliver, default 5, or a per-endpoint MaxDeliveryAttempts override).
  • Dead Letter: When Wolverine's error handling moves a message to the error queue — once its retries are used up, or at once for MoveToErrorQueue() — the poison message is first forwarded to the configured dead-letter subject (so a terminate failure can't lose it), then terminated on the consumer via AckTerminateAsync(reason) so the server stops redelivering and records why. 6.47 This no longer waits for the consumer's MaxDeliver to be used up: Buffered and Inline listeners have acknowledged the delivery by then, so a message moved to the error queue earlier used to be dropped. The copy carries its own Nats-Msg-Id ({original}.dead-letter), so a dead-letter subject inside the original's stream does not have it discarded as a duplicate of the original. If no dead-letter subject is configured, Wolverine logs a warning and the message is terminated without being retained — configure a dead-letter subject to keep poison messages. Messages forwarded to the dead-letter subject carry the standard Wolverine diagnostic headers (exception-type, exception-message, exception-stack, failed-at, original-destination), with the delivery attempt count on the standard attempts header — see diagnostic headers on dead letter messages.

Core NATS ​

  • Retry: Message is republished to the subject
  • Dead Letter: Handled by Wolverine's error handling policies

Auto-Provisioning ​

Enable automatic creation of streams and consumers:

csharp
opts.UseNats("nats://localhost:4222")
    .AutoProvision();

Or use resource setup on startup:

csharp
opts.Services.AddResourceSetupOnStartup();

Existing Streams and Consumers 6.47 ​

By default Wolverine creates a missing declared stream or JetStream listener consumer. A declared stream that already exists is left as it is, because updating it can discard messages. A listener's named consumer that already exists is brought in line with the configuration, so a changed MaxDeliver or AckWait just works. Provisioning() changes what startup does with existing streams and consumers:

csharp
opts.UseNats("nats://localhost:4222")
    // CreateOnly, CreateOrUpdate or Verify
    .Provisioning(NatsProvisioning.CreateOrUpdate)
    .DefineStream("ORDERS", s => s
        .WithSubjects("orders.>")
        .WithLimits(maxBytes: 10L * 1024 * 1024 * 1024));

opts.ListenToNatsSubject("orders.received")
    .UseJetStream("ORDERS", "order-processor")
    .ConfigureDeadLetterQueue(maxDeliveryAttempts: 10);
Provisioning(...)Missing stream or consumerExisting stream that differsExisting named consumer that differs
not set (default)CreatedLeft aloneUpdated
CreateOnlyCreatedLeft aloneLeft alone
CreateOrUpdateCreatedUpdatedUpdated
VerifyStartup failsStartup fails, listing every deviationStartup fails, listing every deviation

If a named consumer is maintained outside the application, for example with the NATS CLI, use Provisioning(NatsProvisioning.CreateOnly) so Wolverine does not overwrite its AckWait, MaxDeliver or filter with its own values.

Only the settings Wolverine itself writes are compared and updated: for a declared stream everything its StreamConfiguration surfaces (subjects, retention, storage, limits, discard policy, replicas, duplicate window and the allow/deny flags), and for a named consumer the explicit ack policy, AckWait, MaxDeliver, the filter subject(s) and a MaxAckPending Wolverine sizes. An update is applied on top of the configuration the server already has, so a description, metadata or any other setting maintained outside the application survives, and so does a consumer's DeliverPolicy.

WARNING

CreateOrUpdate applies what the configuration says. Lowering MaxBytes, MaxMessages or MaxAge makes the server discard messages to fit, and the server refuses changes JetStream does not allow on an existing stream or consumer — a different storage type, a change to or from work-queue retention — which then fails the start.

Verify is meant for streams and consumers provisioned outside the application, for example by infrastructure as code. Stream deviations surface while the transport connects, so Wolverine's usual broker initialization retries apply until WolverineOptions.BrokerInitializationTimeout elapses; a consumer deviation fails the listener as it starts. Verify checks the declared streams whether or not AutoProvision() is on, since it never creates anything itself. resources setup / AddResourceSetupOnStartup() is not affected by this setting; it writes the same consumer settings the listener does, so a start after resource setup finds nothing to reconcile or to fail Verify on.

Subject Prefix ​

When sharing a NATS server between multiple developers or development environments, you can add a prefix to all NATS subjects to isolate each environment's messaging. Use WithSubjectPrefix() or the generic PrefixIdentifiers() method:

csharp
opts.UseNats("nats://localhost:4222")
    .WithSubjectPrefix("myapp");

// Subject "orders" becomes "myapp.orders"

You can also use PrefixIdentifiersWithMachineName() as a convenience to use the current machine name as the prefix:

csharp
opts.UseNats("nats://localhost:4222")
    .PrefixIdentifiersWithMachineName();

Complete Example ​

csharp
using var host = await Host.CreateDefaultBuilder()
    .UseWolverine(opts =>
    {
        opts.UseNats("nats://localhost:4222")
            .AutoProvision()
            .WithCredentials("user", "pass")
            .UseJetStream(js =>
            {
                js.MaxDeliver = 5;
                js.AckWait = TimeSpan.FromSeconds(30);
            })
            .DefineWorkQueueStream("ORDERS", 
                s => s.EnableScheduledDelivery(), 
                "orders.>");

        // Listen to orders with JetStream durability
        opts.ListenToNatsSubject("orders.received")
            .UseJetStream("ORDERS", "order-processor")
            .Named("order-listener");

        // Publish order events
        opts.PublishMessage<OrderCreated>()
            .ToNatsSubject("orders.created");

        opts.PublishMessage<OrderShipped>()
            .ToNatsSubject("orders.shipped");

        opts.Services.AddResourceSetupOnStartup();
    }).StartAsync();

Testing ​

To run tests locally:

bash
# Start NATS with JetStream
docker run -d --name nats -p 4222:4222 -p 8222:8222 nats:latest --jetstream -m 8222

# For scheduled delivery tests, use NATS 2.12+
docker run -d --name nats -p 4222:4222 -p 8222:8222 nats:2.12-alpine --jetstream -m 8222

Global Partitioning ​

NATS subjects can be used as the external transport for global partitioned messaging. This creates a set of sharded NATS subjects with companion local queues for sequential processing across a multi-node cluster.

Use UseShardedNatsSubjects() within a GlobalPartitioned() configuration:

cs
using var host = await Host.CreateDefaultBuilder()
    .UseWolverine(opts =>
    {
        opts.UseNats("nats://localhost:4222").AutoProvision();

        opts.MessagePartitioning.ByMessage<IMyMessage>(x => x.GroupId);

        opts.MessagePartitioning.GlobalPartitioned(topology =>
        {
            // Creates 4 sharded NATS subjects named "orders1" through "orders4"
            // with matching companion local queues for sequential processing
            topology.UseShardedNatsSubjects("orders", 4);
            topology.MessagesImplementing<IMyMessage>();
        });
    }).StartAsync();

This creates NATS subjects named orders1 through orders4 with companion local queues global-orders1 through global-orders4. Messages are routed to the correct shard based on their group id, and Wolverine handles the coordination between nodes automatically.

JetStream is implied

Global partitioning forces every slot into durable mode, and a NATS endpoint can only be durable when it is JetStream-backed. UseShardedNatsSubjects() therefore turns JetStream on for its own endpoints and declares a stream per shard (named after the subject, upper-cased, dots replaced with underscores) so AutoProvision creates it. Declare a stream of the same name yourself if you need different retention or replication.

Shard streams use work-queue retention 6.47

The shard streams UseShardedNatsSubjects() declares use JetStream's work-queue retention (StreamConfigRetention.Workqueue), so a message waits in its shard until it is acknowledged even while no node is consuming that shard — at startup, or while the shard moves to another node. Earlier versions declared them with interest retention, which discards a message that arrives while no consumer is bound. This applies to shard streams created from now on: Wolverine leaves an existing stream as it is, and the server refuses to change an existing stream to or from work-queue retention, so recreate an old shard stream (once it is drained) to switch it.

URI reference ​

The NatsEndpointUri helper class builds canonical endpoint URIs:

URI formHelper call
nats://subject/{subject}NatsEndpointUri.Subject("subject")
csharp
using Wolverine.Nats;

var uri = NatsEndpointUri.Subject("orders.created");

Released under the MIT License.