NetPub 1.2.0

dotnet add package NetPub --version 1.2.0
                    
NuGet\Install-Package NetPub -Version 1.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="NetPub" Version="1.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="NetPub" Version="1.2.0" />
                    
Directory.Packages.props
<PackageReference Include="NetPub" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add NetPub --version 1.2.0
                    
#r "nuget: NetPub, 1.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package NetPub@1.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=NetPub&version=1.2.0
                    
Install as a Cake Addin
#tool nuget:?package=NetPub&version=1.2.0
                    
Install as a Cake Tool

NetPub

NetPub is a small, Native AOT friendly publish/subscribe layer on top of Azure Service Bus, RabbitMQ and in-memory subscriber registrations.

You describe your messaging topology with attributes, and a set of Roslyn incremental source generators writes the plumbing at compile time:

  • the provider-specific subscriber implementation of your handler,
  • the queue/topic/subscription wiring,
  • a single services.AddNetPub(connectionString) entry point that registers everything — across every project of the solution that declares NetPub types.

No reflection, no MakeGenericType, no Activator.CreateInstance, no assembly scanning at runtime. Everything the library needs to know is baked into generated C#.


Table of contents


Installation

dotnet add package NetPub

The NetPub package ships the runtime assemblies (Azure Service Bus, RabbitMQ and in-memory providers) and the source generators (under analyzers/dotnet/cs). Installing it is all you need: nothing else has to be referenced or enabled.

Analyzers are not transitive in NuGet. If you reference NetPub.AzureServiceBus or NetPub.RabbitMq directly instead of NetPub, you get the runtime API but not the generators. In a multi-project solution add NetPub to every project that declares messages, subscribers or configuration classes, including the host.


Quick start

1. Describe a message

A message is a record deriving from Message<TPayload>. The base record carries the correlation id, the producing service and the payload.

using NetPub.Contracts.Messaging;

public record OrderCreatedMessage(
    string Id,
    string Source,
    OrderCreatedPayload Payload)
    : Message<OrderCreatedPayload>(Id, Source, Payload);

public record OrderCreatedPayload
{
    public string OrderNumber { get; set; } = string.Empty;

    public decimal Total { get; set; }
}

2. Declare a subscriber

Write a partial class, annotate it, and implement the handler. You never write : ISubscriber<OrderCreatedMessage, OrderCreatedPayload> yourself — the generator adds the base list for you, so pressing <kbd>Alt</kbd>+<kbd>Enter</kbd> offers to implement HandleAsync with the right signature.

using NetPub.Contracts.Messaging;

[HandlesMessage<OrderCreatedMessage>]
[Transport(Transport.Queue)]
public sealed partial class OrderCreatedSubscriber(ILogger<OrderCreatedSubscriber> logger)
{
    public ValueTask HandleAsync(
        OrderCreatedMessage message,
        CancellationToken cancellationToken = default)
    {
        logger.LogInformation("Order {OrderNumber} created", message.Payload.OrderNumber);

        return ValueTask.CompletedTask;
    }
}

Subscribers are registered as scoped services, so constructor injection works as usual.

3. Register everything

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddNetPub(builder.Configuration.GetConnectionString("ServiceBus")!);

await builder.Build().RunAsync();

AddNetPub is generated in the NetPub namespace for the assembly being compiled, and it already contains every queue, topic, subscription and subscriber declared in that assembly and in every referenced assembly that uses NetPub (see Multi-project solutions). On startup NetPub creates the missing entities (idempotently) and starts one processor per message type.

4. Publish

using NetPub.Publishing;

public sealed class OrderService(IPublisher publisher)
{
    public ValueTask PlaceOrderAsync(string orderNumber, decimal total) =>
        publisher.PublishAsync(
            new OrderCreatedMessage(
                Guid.NewGuid().ToString("N"),
                "orders-api",
                new OrderCreatedPayload { OrderNumber = orderNumber, Total = total }));
}

The destination (queue, topic or in-memory subscriber set) is resolved from the message type, so there is nothing else to pass.


Attribute reference

All attributes live in NetPub.Contracts.Messaging.

Attribute Applies to Required Default Purpose
[HandlesMessage<TMessage>] partial class yes, on subscribers Marks the class as a subscriber of TMessage and drives the generated implementation.
[Transport(Transport.Queue \| Transport.Topic \| Transport.Memory)] subscriber class, message record no Queue Chooses the transport. On a message record it declares the corresponding entity even when nobody subscribes; Memory never creates a broker entity.
[Provider(MessagingProvider.AzureServiceBus \| MessagingProvider.RabbitMq \| MessagingProvider.InMemory)] subscriber class, message record, configuration class no AzureServiceBus Selects the messaging provider. On a message record it is inherited by every subscriber and configuration class of that message, so it only has to be written once.
[SubscriberCount(n)] subscriber class no 1 Maximum concurrent handler invocations (MaxConcurrentCalls/PrefetchCount on Azure queues and subscriptions, consumer prefetch on RabbitMQ).
[Retry(maxAttempts, InitialDelayMilliseconds = 200, MaxDelayMilliseconds = 30000)] subscriber class no no retry Retries the handler in-process with exponential backoff before the message is dead-lettered. See Retries and dead-lettering.
[QueueConfiguration<TMessage>] partial class no Marks a class that customises the queue options of TMessage (CreateQueueOptions or RabbitMqQueueOptions).
[TopicConfiguration<TMessage>] partial class no Marks a class that customises the topic/exchange options of TMessage (CreateTopicOptions or RabbitMqTopicOptions).
[SubscriberConfiguration<TSubscriber>] partial class no Marks a class that customises the subscription options used by TSubscriber (CreateSubscriptionOptions or RabbitMqSubscriptionOptions).

Requirements checked at compile time: annotated types must be partial, non generic and not nested; the type argument of [HandlesMessage<T>] must derive from Message<TPayload>.


How the topology is resolved

For every message type the generator picks one entity, using this precedence:

  1. [QueueConfiguration<TMessage>] / [TopicConfiguration<TMessage>] — explicit, and the entity name comes from the configuration class.
  2. [Transport(...)] on the message record — entity named after the message type.
  3. The transport declared by the subscribers of that message — entity named after the message type.
// 1. explicit: queue "netpub-orders"
[QueueConfiguration<OrderCreatedMessage>]
public sealed partial class OrderCreatedQueueConfiguration { /* ... */ }

// 2. publish only: topic "TelemetryMessage", no subscription
[Transport(Transport.Topic)]
public record TelemetryMessage(string Id, string Source, TelemetryPayload Payload)
    : Message<TelemetryPayload>(Id, Source, Payload);

// 3. implicit: topic "AlertMessage" + subscription "AlertMessage"
[HandlesMessage<AlertMessage>]
[Transport(Transport.Topic)]
public sealed partial class AlertSubscriber { /* ... */ }

A message can never be bound to both a queue and a topic — that is reported as NETPUB007 at compile time.


Configuration classes

A configuration class is a partial class with a Configure method. The generator adds the matching interface (IQueueConfiguration, ITopicConfiguration or ISubscriptionConfiguration) to the base list, and for subscription configurations it also emits public static Type GetSubscriberType().

[QueueConfiguration<OrderCreatedMessage>]
public sealed partial class OrderCreatedQueueConfiguration
{
    public CreateQueueOptions Configure(CreateQueueOptions options)
    {
        options.Name = "netpub-orders";
        options.MaxDeliveryCount = 6;
        options.LockDuration = TimeSpan.FromSeconds(45);

        return options;
    }
}

[TopicConfiguration<CustomerRegisteredMessage>]
public sealed partial class CustomerRegisteredTopicConfiguration
{
    public CreateTopicOptions Configure(CreateTopicOptions options)
    {
        options.Name = "netpub-customers";
        options.SupportOrdering = true;

        return options;
    }
}

[SubscriberConfiguration<CustomerAuditSubscriber>]
public sealed partial class CustomerAuditSubscriptionConfiguration
{
    public CreateSubscriptionOptions Configure(CreateSubscriptionOptions options)
    {
        options.SubscriptionName = "netpub-customers-audit";
        options.MaxDeliveryCount = 7;

        return options;
    }
}

Mutate the instance you receive. The generated code invokes Configure(options) for its side effects and ignores the returned reference, so returning a brand new object has no effect.

CreateSubscriptionOptions.TopicName is always overwritten with the resolved topic name, so you cannot desynchronise a subscription from its topic.

Only one queue/topic configuration class per message type is allowed (NETPUB008), and a configuration class must resolve to a remote provider: the in-memory provider has no entities to configure (NETPUB009).

The option types depend on the provider: Azure Service Bus uses the Create*Options classes of the Azure SDK, RabbitMQ uses RabbitMqQueueOptions, RabbitMqTopicOptions and RabbitMqSubscriptionOptions from NetPub.RabbitMq.Configuration.


Several subscribers on the same message

Multiple subscriber classes can handle the same message type. They share a single Service Bus entity (one queue, or one topic subscription) and every handler is invoked for each received message.

[HandlesMessage<CustomerRegisteredMessage>]
[Transport(Transport.Topic)]
[SubscriberCount(4)]
public sealed partial class CustomerAuditSubscriber { /* ... */ }

[HandlesMessage<CustomerRegisteredMessage>]
[Transport(Transport.Topic)]
public sealed partial class CustomerWelcomeSubscriber { /* ... */ }

Both classes are resolved from the same DI scope and awaited in sequence. When the counts differ, the highest [SubscriberCount] wins for the processor concurrency.


Retries and dead-lettering

By default a handler that throws fails the delivery immediately: Azure Service Bus abandons the message (the broker redelivers it until MaxDeliveryCount moves it to the dead-letter queue) and RabbitMQ nacks it. [Retry] adds an in-process retry loop in front of that:

[HandlesMessage<PaymentAttemptedMessage>]
[Retry(5, InitialDelayMilliseconds = 100, MaxDelayMilliseconds = 5_000)]
public sealed partial class PaymentAttemptedSubscriber
{
    public ValueTask HandleAsync(PaymentAttemptedMessage message, CancellationToken cancellationToken = default)
        => /* ... */;
}
  • maxAttempts counts the first invocation too: [Retry(5)] means one attempt plus four retries.

  • The delay doubles after every failure (100 ms, 200 ms, 400 ms, …) and is capped at MaxDelayMilliseconds. An OperationCanceledException is never retried.

  • The policy is captured at compile time: the generator emits public static RetryPolicy? GetRetryPolicy() on the subscriber and the configuration builders store it in RetryPoliciesBySubscriberType. Hand-written subscribers can opt in by implementing the same static method (it defaults to null).

  • Once the attempts are exhausted the dispatcher throws RetryExhaustedException (SubscriberType, Attempts, the last failure as InnerException) and hands the message over to the broker's dead-letter mechanism:

    Provider Exhausted retry Other failure
    Azure Service Bus DeadLetterMessageAsync with reason RetryExhausted and the failure message as description. AbandonMessageAsync, exception rethrown to the processor.
    RabbitMQ basic.nack with requeue: false, so the message follows x-dead-letter-exchange when configured. basic.nack honouring RequeueOnFailure.
    In-memory RetryExhaustedException surfaces to the PublishAsync caller. The handler exception surfaces to the caller.

Every retry is logged at Warning and counted by the netpub.subscriber.retries meter (see Observability). The lock/ack of the broker message stays open while the loop runs, so keep maxAttempts × MaxDelay well below the lock duration (LockDuration on Azure, consumer timeout on RabbitMQ).


RabbitMQ provider

NetPub.RabbitMq maps the same attribute model onto AMQP primitives:

NetPub concept RabbitMQ
Queue (Transport.Queue) A durable queue named after the message (or after the [QueueConfiguration]). Messages are published to the default exchange with the queue name as routing key.
Topic (Transport.Topic) An exchange named after the message (or after the [TopicConfiguration]), fanout by default.
Subscription A durable queue bound to the exchange with RabbitMqSubscriptionOptions.RoutingKey (empty by default).
[SubscriberCount(n)] Consumer dispatch concurrency and basic.qos prefetch.

Messages are published as persistent JSON with content_type: application/json and publisher confirms enabled. A handled message is acked; a failed one is nacked without requeue by default, so a poison message is dropped or routed to the dead letter exchange configured through Arguments (x-dead-letter-exchange). Set RequeueOnFailure = true on the queue or subscription options to redeliver it instead.

Declare the provider once on the message and everything attached to it follows:

[Provider(MessagingProvider.RabbitMq)]
public record OrderCreatedMessage(string Id, string Source, OrderCreatedPayload Payload)
    : Message<OrderCreatedPayload>(Id, Source, Payload);

[HandlesMessage<OrderCreatedMessage>]
public sealed partial class OrderCreatedSubscriber { /* ... */ }

[QueueConfiguration<OrderCreatedMessage>]
public sealed partial class OrderCreatedQueueConfiguration
{
    public RabbitMqQueueOptions Configure(RabbitMqQueueOptions options)
    {
        options.Name = "netpub-orders";
        options.Arguments["x-queue-type"] = "quorum";

        return options;
    }
}

Registration goes through the generated AddNetPub, exactly like Azure Service Bus:

// AMQP URI, e.g. amqp://guest:guest@localhost:5672/
services.AddNetPub(builder.Configuration.GetConnectionString("RabbitMq")!);

or, manually, through services.AddRabbitMq(connectionString, configure) / services.AddRabbitMq(IConnectionFactory, configure). One shared IConnection is opened lazily; the topology is declared by a hosted service on startup and one channel per message type is used for consuming. Publishing goes through a pool of channels with publisher confirms enabled — sized to the processor count (1–16) by default, or explicitly with builder.UsePublishChannels(n).


Multi-project solutions

Every assembly that references NetPub gets its own generated module:

[assembly: NetPubModule(typeof(NetPub.Generated.MyCompanyOrdersNetPubModule))]

namespace NetPub.Generated;

public static class MyCompanyOrdersNetPubModule
{
    public static void ConfigureAzureServiceBus(AzureServiceBusConfigurationBuilder builder) { /* ... */ }
    public static void ConfigureRabbitMq(RabbitMqConfigurationBuilder builder) { /* ... */ }
    public static void ConfigureInMemory(InMemoryConfigurationBuilder builder) { /* ... */ }
}

The module only contains the Configure* methods for the providers actually used by that assembly. AddNetPub is then generated once per compilation and aggregates the modules of every referenced assembly plus the local one, so a host referencing Orders, Billing and Shipping calls a single method:

// Program.cs of the host — registers Orders + Billing + Shipping + the host's own declarations
builder.Services.AddNetPub(connectionString);

Rules:

  • AddNetPub and NetPubBuilder are emitted as internal types so that several generating assemblies can coexist in the same solution without ambiguous calls.
  • Modules are applied in ordinal order of their assembly name, the local module last, and the optional configure callback after everything else.
  • Entities inferred from messages or subscribers are registered with EnsureQueue/EnsureTopic, which yields to an explicit AddQueue/AddTopic from any assembly. Two assemblies can thus share a message type without duplicating the entity.
  • A referenced assembly that declares Azure Service Bus or RabbitMQ subscribers forces the host to configure that provider (see the overload rules below).

Publishing

IPublisher is registered as a singleton:

public interface IPublisher
{
    ValueTask PublishAsync<TMessage>(TMessage message, CancellationToken cancellationToken = default)
        where TMessage : class, IMessage;

    ValueTask PublishAsync<TMessage, TPayload>(TMessage message, CancellationToken cancellationToken = default)
        where TMessage : Message<TPayload>;
}

The single type parameter overload is inferred from the argument, so publisher.PublishAsync(message) is normally all you need. The publisher asks each registered IPublishingProvider (Azure Service Bus, RabbitMQ, in-memory) whether it owns the message type and hands the message to every one that does; the answer is cached per message type, so steady-state routing is a dictionary lookup. Publishing a message with no configured provider throws InvalidOperationException. For manual configuration, call services.AddPublisher() after registering the providers; a custom transport can plug in with services.AddPublishingProvider<TProvider>().

Transport metadata

IMessage exposes Id and Source (both already part of Message<TPayload>), and the providers stamp them on the wire so that brokers, tooling and consumers written in other stacks can correlate messages without opening the body:

Field Azure Service Bus RabbitMQ
Id MessageId, CorrelationId message_id, correlation_id
Source ApplicationProperties["netpub.source"] app_id
CLR type name Subject type
Body application/json, UTF-8 content_type: application/json, persistent

Observability

NetPub emits OpenTelemetry compatible traces and metrics through System.Diagnostics. Nothing is recorded unless a listener is attached, so the instrumentation is free when disabled.

services.AddOpenTelemetry()
    .WithTracing(tracing => tracing.AddSource("NetPub"))
    .WithMetrics(metrics => metrics.AddMeter("NetPub"));

Traces (ActivitySource "NetPub"): one producer span named <destination> publish per publication and one consumer span named <destination> process per received message, tagged with messaging.system (servicebus, rabbitmq, inmemory), messaging.destination.name, messaging.operation.type and netpub.message.type. The broker SDKs already propagate the trace context (Diagnostic-Id / traceparent), so the spans nest under the producer trace automatically.

Metrics (Meter "NetPub"):

Instrument Type Tags
netpub.messages.published counter messaging.system, messaging.destination.name, netpub.message.type
netpub.messages.processed counter … plus netpub.outcome (success / failure)
netpub.message.processing.duration histogram (ms) same as processed
netpub.subscriber.retries counter messaging.system, netpub.subscriber

Performance notes

The hot paths are allocation-conscious by design:

  • SerializationMessageSerializer.Serialize writes through a per-thread Utf8JsonWriter into an ArrayPool<byte> buffer and returns a PooledMessageBody; the JsonTypeInfo<T> is resolved once per message type. The providers hand the pooled memory straight to the SDK and return it after the send. Use SerializeToArray when you need an owned byte[].
  • RoutingPublisher caches the providers accepting each message type in a ConcurrentDictionary<Type, IPublishingProvider[]>; the single-provider case is a direct call.
  • Dispatch — subscribers are resolved as IEnumerable<T> and iterated as the array the container materialises (no ToArray()), inside CreateAsyncScope(). Retry state is passed to a static lambda, so a successful first attempt allocates nothing on top of the handler itself.
  • Azure Service BusPrefetchCount follows MaxConcurrentCalls, and queue processors honour [SubscriberCount] exactly like topic subscriptions do.
  • RabbitMQ — deliveries are consumed by a raw AsyncDefaultBasicConsumer subclass (no event handler indirection), the body is deserialized synchronously before the first await (it is only valid during the callback), exchange/routing key pairs are pre-encoded as CachedString, and publishing rents a confirmed channel from a bounded pool instead of opening one per message.

What the generators emit

Three incremental generators run over your compilation.

SubscriberGenerator — for each [HandlesMessage<T>] class:

// <auto-generated/>
#nullable enable
namespace MyApp;

public partial class OrderCreatedSubscriber
    : global::NetPub.AzureServiceBus.Subscribing.ISubscriber<
        global::MyApp.OrderCreatedMessage,
        global::MyApp.OrderCreatedPayload>
{
    public static global::System.Type GetMessageType() => typeof(global::MyApp.OrderCreatedMessage);

    public static global::NetPub.Contracts.Shared.SubscriberTransport GetTransport() => /* ... */;

    public static int GetSubscriberCount() => 1;

    // Only when the class carries [Retry(...)]:
    public static global::NetPub.Contracts.Resilience.RetryPolicy? GetRetryPolicy() =>
        new global::NetPub.Contracts.Resilience.RetryPolicy(
            5,
            global::System.TimeSpan.FromMilliseconds(100),
            global::System.TimeSpan.FromMilliseconds(5000));

    public static global::NetPub.AzureServiceBus.Subscribing.ISubscriberConfiguration MakeSubscription(/* ... */) =>
        global::NetPub.AzureServiceBus.Subscribing.SubscriberRegistrationFactory
            .Create<global::MyApp.OrderCreatedMessage, global::MyApp.OrderCreatedPayload>(/* ... */);
}

These static members implement the static abstract members of ICreatableRegistration, which is how NetPub builds a strongly typed registration without any runtime generic instantiation.

For an in-memory subscriber, the generated implementation instead implements IInMemorySubscriber<TMessage, TPayload> and its MakeSubscription method creates an IInMemorySubscriberConfiguration. AddNetPub passes those registrations to AddInMemory; publishing then uses a typed double-dispatch bridge to resolve and invoke every matching subscriber.

ConfigurationGenerator — adds the configuration interface to your partial configuration classes.

RegistrationGenerator — emits two files:

  1. NetPubModule.g.cs: the per-assembly module described in Multi-project solutions, with one Configure<Provider> method per provider used by the assembly. Configuration classes turn into AddQueue/AddTopic calls, inferred entities into EnsureQueue/EnsureTopic, subscribers into AddSubscriber:

    public static void ConfigureAzureServiceBus(
        global::NetPub.AzureServiceBus.Configuration.AzureServiceBusConfigurationBuilder builder)
    {
        builder.AddQueue<global::MyApp.OrderCreatedMessage>(
            options => new global::MyApp.OrderCreatedQueueConfiguration().Configure(options));
        builder.EnsureTopic<global::MyApp.AlertMessage>();
        builder.AddSubscriber<global::MyApp.OrderCreatedSubscriber>();
        builder.AddSubscriber<global::MyApp.CustomerAuditSubscriber>(
            options => new global::MyApp.CustomerAuditSubscriptionConfiguration().Configure(options));
    }
    
  2. NetPubServiceCollectionExtensions.g.cs: the internal AddNetPub overloads plus an internal sealed class NetPubBuilder. Each UseAzureServiceBus/UseRabbitMq call invokes the matching Configure* of every module (referenced assemblies first, local module last) and then the user callback; in-memory declarations are always registered.

    internal static IServiceCollection AddNetPub(
        this IServiceCollection services,
        Action<global::NetPub.NetPubBuilder> configure)
    {
        var builder = new global::NetPub.NetPubBuilder(services);
        configure(builder);
        builder.Complete();
    
        return services;
    }
    

Nothing is emitted if neither the assembly nor its references declare any NetPub type, or if the assembly does not reference Microsoft.Extensions.DependencyInjection.Abstractions.

To inspect the generated files locally:

dotnet build --no-incremental /p:EmitCompilerGeneratedFiles=true /p:CompilerGeneratedFilesOutputPath=<path outside the project>

Never point CompilerGeneratedFilesOutputPath inside the project folder: the files would be compiled twice and you would get CS0111/CS1024.


AddNetPub overloads and escape hatch

Which overloads exist depends on the remote providers (Azure Service Bus, RabbitMQ) used by the compilation and its referenced modules:

// Always available: the builder overload
services.AddNetPub(netPub => netPub
    .UseAzureServiceBus(serviceBusConnectionString, azure => { /* escape hatch */ })
    .UseRabbitMq(amqpUri, rabbit => { /* escape hatch */ }));

// Only Azure Service Bus subscribers/entities
services.AddNetPub(connectionString);
services.AddNetPub(connectionString, configure);
services.AddNetPub(connectionString, administrationConnectionString);
services.AddNetPub(connectionString, administrationConnectionString, configure);

// Only RabbitMQ subscribers/entities
services.AddNetPub(amqpUri);
services.AddNetPub(amqpUri, configure);

// In-memory subscribers present (with or without remote providers)
services.AddNetPub();
  • The Azure two parameter overloads use the same connection string for messaging and for the management API. Separate strings are useful when the management endpoint differs, for example against the Azure Service Bus emulator.
  • When both remote providers are in play only the builder overload is generated, so the connection strings cannot be mixed up. Complete() throws an InvalidOperationException if a remote provider has subscribers but its Use* method was not called.
  • services.AddNetPub() registers only the in-memory subscribers. It is handy in tests and in assemblies that do not talk to a broker.
  • NetPubBuilder.UseRabbitMq also accepts a pre-configured RabbitMQ.Client.IConnectionFactory.
  • configure is invoked after every generated registration, so it can add anything the generator cannot infer:
services.AddNetPub(
    connectionString,
    builder => builder.AddQueue<LegacyMessage>(options => options.Name = "legacy-queue"));

Manual configuration without generators

The generators are optional sugar. AzureServiceBusConfigurationBuilder, RabbitMqConfigurationBuilder and InMemoryConfigurationBuilder are public and can be used directly. Azure subscribers implement ISubscriber<TMessage, TPayload>, RabbitMQ subscribers IRabbitMqSubscriber<TMessage, TPayload> and in-memory subscribers IInMemorySubscriber<TMessage, TPayload>:

using NetPub.Publishing;

services.AddAzureServiceBus(connectionString, builder =>
{
    builder.AddQueue<OrderCreatedMessage>(options => options.Name = "netpub-orders");
    builder.AddQueueSubscriber<OrderCreatedSubscriber>();

    builder.AddTopic<CustomerRegisteredMessage>(options => options.Name = "netpub-customers");
    builder.AddTopicSubscriber<CustomerAuditSubscriber>(subscriberCount: 4);

    // Deferred default: creates the queue only if nobody registers an explicit one.
    builder.EnsureQueue<LegacyMessage>();

    // Native AOT: plug your JsonSerializerContext in.
    builder.UseSerializerOptions(AppJsonContext.Default.Options);
});

services.AddRabbitMq(amqpUri, builder =>
{
    builder.AddQueue<InvoiceIssuedMessage>(options => options.Name = "netpub-invoices");
    builder.AddQueueSubscriber<InvoiceIssuedSubscriber>();
});

services.AddInMemory(builder =>
{
    builder.AddSubscriber<InMemorySubscriber>();
});

services.AddPublisher();

Diagnostics

Id Severity Meaning
NETPUB001 Error The annotated type must be declared partial.
NETPUB002 Error The annotated type must not be generic.
NETPUB003 Error The annotated type must not be nested.
NETPUB004 Error The handled message must derive from Message<TPayload>.
NETPUB005 Error The requested messaging provider is not supported.
NETPUB006 Error [SubscriberCount] must be greater than zero.
NETPUB007 Error The same message cannot use both a queue and a topic.
NETPUB008 Error A message can only have one entity configuration class.
NETPUB009 Error Queue, topic and subscription configuration classes require a remote provider (Azure Service Bus or RabbitMQ).
NETPUB010 Error [Retry] attempts must be greater than zero and the delays non-negative, with MaxDelayMilliseconds >= InitialDelayMilliseconds.

Native AOT

NetPub is designed to stay trimmable and AOT safe (IsAotCompatible is enabled on every runtime project and the build is warning free):

  • no Activator.CreateInstance, MakeGenericType or MakeGenericMethod;
  • generic instantiations are materialised by generated C#, so the ILC compiler sees them statically;
  • subscriber metadata is exposed through static abstract interface members instead of reflection, and the generated code carries the [DynamicallyAccessedMembers] annotations the DI container needs;
  • no assembly scanning: the registration list is a compile time constant;
  • every awaited call on the hot paths uses ConfigureAwait(false).

JSON serialisation goes through System.Text.Json. The default options rely on reflection, which is fine for JIT deployments; for a trimmed/AOT build hand a source generated JsonSerializerContext to the provider:

[JsonSerializable(typeof(OrderCreatedMessage))]
internal partial class AppJsonContext : JsonSerializerContext;

services.AddNetPub(connectionString, builder =>
    builder.UseSerializerOptions(AppJsonContext.Default.Options));

Breaking changes

Compared to the previous releases:

  • AddNetPub and NetPubBuilder are now generated as internal types, and the shape of the overloads depends on the providers in use (see above). Add the NetPub package to the host project if it was only referenced by class libraries.
  • Publisher takes an IEnumerable<IPublishingProvider> instead of the individual publishers; AzureServiceBusPublisher/InMemoryPublisher were replaced by AzureServiceBusPublishingProvider/InMemoryPublishingProvider. Code using services.AddPublisher() and IPublisher is unaffected.
  • Inferred entities are registered with EnsureQueue/EnsureTopic. An explicit AddQueue / AddTopic for the same message no longer throws "already registered" — it simply wins.
  • AddTopicSubscriber overloads were collapsed: (int subscriberCount = 1, Action? configure = null) and (Action configure, int subscriberCount = 1).
  • IMessage now exposes Id and Source. Types deriving from Message<TPayload> already satisfy it; hand-rolled implementations must add the two properties.
  • MessageSerializer.Serialize returns a PooledMessageBody that must be disposed; use SerializeToArray for the previous byte[] behaviour.
  • ICreatableRegistration (and the RabbitMQ/in-memory counterparts) gained static abstract RetryPolicy? GetRetryPolicy(). Generated subscribers and every subscriber implementing ISubscriber<,> / IRabbitMqSubscriber<,> / IInMemorySubscriber<,> get a null default for free.
  • Azure Service Bus queue processors now honour [SubscriberCount] (and the new AddQueueSubscriber(int subscriberCount) parameter) and set PrefetchCount = MaxConcurrentCalls. Queue subscribers that were relying on the implicit MaxConcurrentCalls = 1 and declared a higher count will now run concurrently.
  • InMemoryMessageDispatcher takes (IServiceScopeFactory, InMemoryConfiguration, ILogger?); it is registered by AddInMemory, so only code constructing it by hand is affected.

Repository layout

Project Description
src/NetPub The package users install: facade plus the packaged analyzers.
src/NetPub.Contracts Messages, payload contracts, every NetPub attribute, the pooled MessageSerializer, RetryPolicy/RetryExecutor and the NetPubDiagnostics OpenTelemetry surface.
src/NetPub.AzureServiceBus Azure Service Bus dispatcher, topology initializer, publishing provider and configuration builder.
src/NetPub.RabbitMq RabbitMQ dispatcher, topology initializer, publishing provider and configuration builder.
src/NetPub.InMemory In-memory subscriber registrations and dependency-injection integration.
src/NetPub.Publishing Common publisher and the IPublishingProvider routing contract.
src/NetPub.SourceGenerators The three incremental generators (netstandard2.0).
test/NetPub.AzureServiceBus.Tests Unit tests, no infrastructure required.
test/NetPub.Tests.ReferencedLibrary Class library with its own generated module, referenced by the unit tests to cover multi-project aggregation.
test/NetPub.AzureServiceBus.IntegrationTests End to end tests on the Azure Service Bus emulator via Testcontainers.
test/NetPub.RabbitMq.IntegrationTests End to end tests on a RabbitMQ broker via Testcontainers.

Building and testing

dotnet build NetPub.slnx

# unit tests
dotnet test test/NetPub.AzureServiceBus.Tests/NetPub.AzureServiceBus.Tests.csproj

# integration tests (require a running Docker daemon)
dotnet test test/NetPub.AzureServiceBus.IntegrationTests/NetPub.AzureServiceBus.IntegrationTests.csproj
dotnet test test/NetPub.RabbitMq.IntegrationTests/NetPub.RabbitMq.IntegrationTests.csproj

dotnet pack src/NetPub/NetPub.csproj -c Release

The Azure suite starts the official servicebus-emulator image together with SQL Server, the RabbitMQ suite starts rabbitmq:4-management-alpine. Both build a host through the generated AddNetPub, and assert both the created topology and the real end to end delivery.


Analyzer release tracking

src/NetPub.SourceGenerators contains AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md. They are not documentation: the Microsoft.CodeAnalysis.Analyzers package reads them (they are declared as AdditionalFiles) to enforce that every diagnostic id is tracked, and fails the build with RS2008 when a descriptor is missing.

  • AnalyzerReleases.Unshipped.md lists the rules added since the last released version. New ids go here, under ### New Rules.
  • AnalyzerReleases.Shipped.md is the history of published releases. At release time the content of the unshipped file is moved under a new ## Release X.Y heading and the unshipped file is emptied.

Both files use the same table format:

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
NETPUB009 | NetPub | Warning | Short description

The shipped file additionally supports ### Removed Rules and ### Changed Rules sections, so consumers can see when a rule disappeared or when its default severity changed.

The promotion is a manual release step, but it is scripted:

pwsh ./scripts/promote-analyzer-release.ps1 -Version 1.0

Releasing

  1. Add or update the pending rules in AnalyzerReleases.Unshipped.md if the release introduces new diagnostics.

  2. Promote them and commit the result:

    pwsh ./scripts/promote-analyzer-release.ps1 -Version 1.0
    git commit -am "Prepare release 1.0"
    
  3. Publish a GitHub release whose tag is the package version, optionally prefixed with v (v1.0.0 and 1.0.0 both produce version 1.0.0):

    gh release create v1.0.0 --generate-notes
    

The Publish NuGet packages workflow reacts to the published release, builds, runs the unit tests, packs the six packages (NetPub, NetPub.Contracts, NetPub.Publishing, NetPub.AzureServiceBus, NetPub.RabbitMq, NetPub.InMemory) with that version and pushes them to NuGet.org. It also asserts that analyzers/dotnet/cs/NetPub.SourceGenerators.dll is present in the NetPub package before publishing.

Pushes to master still publish a 0.0.1-ci.<run number> prerelease, and workflow_dispatch lets you pick an arbitrary version. The CI workflow is the one running the integration suite, since it needs a Docker daemon.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.0 73 9/3/2026
1.1.0 61 8/30/2026
1.0.1 73 8/27/2026
0.0.1-ci.13 60 9/6/2026
0.0.1-ci.11 56 9/3/2026
0.0.1-ci.9 53 8/30/2026
0.0.1-ci.8 62 8/29/2026
0.0.1-ci.6 64 8/27/2026