NetPub 1.2.0
dotnet add package NetPub --version 1.2.0
NuGet\Install-Package NetPub -Version 1.2.0
<PackageReference Include="NetPub" Version="1.2.0" />
<PackageVersion Include="NetPub" Version="1.2.0" />
<PackageReference Include="NetPub" />
paket add NetPub --version 1.2.0
#r "nuget: NetPub, 1.2.0"
#:package NetPub@1.2.0
#addin nuget:?package=NetPub&version=1.2.0
#tool nuget:?package=NetPub&version=1.2.0
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
- Quick start
- Attribute reference
- How the topology is resolved
- Configuration classes
- Several subscribers on the same message
- Retries and dead-lettering
- RabbitMQ provider
- Multi-project solutions
- Publishing
- Observability
- Performance notes
- What the generators emit
AddNetPuboverloads and escape hatch- Manual configuration without generators
- Diagnostics
- Native AOT
- Breaking changes
- Repository layout
- Building and testing
- Analyzer release tracking
- Releasing
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.AzureServiceBusorNetPub.RabbitMqdirectly instead ofNetPub, you get the runtime API but not the generators. In a multi-project solution addNetPubto 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:
[QueueConfiguration<TMessage>]/[TopicConfiguration<TMessage>]— explicit, and the entity name comes from the configuration class.[Transport(...)]on the message record — entity named after the message type.- 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.TopicNameis 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)
=> /* ... */;
}
maxAttemptscounts 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 atMaxDelayMilliseconds. AnOperationCanceledExceptionis 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 inRetryPoliciesBySubscriberType. Hand-written subscribers can opt in by implementing the same static method (it defaults tonull).Once the attempts are exhausted the dispatcher throws
RetryExhaustedException(SubscriberType,Attempts, the last failure asInnerException) and hands the message over to the broker's dead-letter mechanism:Provider Exhausted retry Other failure Azure Service Bus DeadLetterMessageAsyncwith reasonRetryExhaustedand the failure message as description.AbandonMessageAsync, exception rethrown to the processor.RabbitMQ basic.nackwithrequeue: false, so the message followsx-dead-letter-exchangewhen configured.basic.nackhonouringRequeueOnFailure.In-memory RetryExhaustedExceptionsurfaces to thePublishAsynccaller.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:
AddNetPubandNetPubBuilderare emitted asinternaltypes 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
configurecallback after everything else. - Entities inferred from messages or subscribers are registered with
EnsureQueue/EnsureTopic, which yields to an explicitAddQueue/AddTopicfrom 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:
- Serialization —
MessageSerializer.Serializewrites through a per-threadUtf8JsonWriterinto anArrayPool<byte>buffer and returns aPooledMessageBody; theJsonTypeInfo<T>is resolved once per message type. The providers hand the pooled memory straight to the SDK and return it after the send. UseSerializeToArraywhen you need an ownedbyte[]. - Routing —
Publishercaches the providers accepting each message type in aConcurrentDictionary<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 (noToArray()), insideCreateAsyncScope(). Retry state is passed to astaticlambda, so a successful first attempt allocates nothing on top of the handler itself. - Azure Service Bus —
PrefetchCountfollowsMaxConcurrentCalls, and queue processors honour[SubscriberCount]exactly like topic subscriptions do. - RabbitMQ — deliveries are consumed by a raw
AsyncDefaultBasicConsumersubclass (no event handler indirection), the body is deserialized synchronously before the firstawait(it is only valid during the callback), exchange/routing key pairs are pre-encoded asCachedString, 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:
NetPubModule.g.cs: the per-assembly module described in Multi-project solutions, with oneConfigure<Provider>method per provider used by the assembly. Configuration classes turn intoAddQueue/AddTopiccalls, inferred entities intoEnsureQueue/EnsureTopic, subscribers intoAddSubscriber: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)); }NetPubServiceCollectionExtensions.g.cs: theinternalAddNetPuboverloads plus aninternal sealed class NetPubBuilder. EachUseAzureServiceBus/UseRabbitMqcall invokes the matchingConfigure*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
CompilerGeneratedFilesOutputPathinside the project folder: the files would be compiled twice and you would getCS0111/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 anInvalidOperationExceptionif a remote provider has subscribers but itsUse*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.UseRabbitMqalso accepts a pre-configuredRabbitMQ.Client.IConnectionFactory.configureis 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,MakeGenericTypeorMakeGenericMethod; - generic instantiations are materialised by generated C#, so the ILC compiler sees them statically;
- subscriber metadata is exposed through
static abstractinterface 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:
AddNetPubandNetPubBuilderare now generated asinternaltypes, and the shape of the overloads depends on the providers in use (see above). Add theNetPubpackage to the host project if it was only referenced by class libraries.Publishertakes anIEnumerable<IPublishingProvider>instead of the individual publishers;AzureServiceBusPublisher/InMemoryPublisherwere replaced byAzureServiceBusPublishingProvider/InMemoryPublishingProvider. Code usingservices.AddPublisher()andIPublisheris unaffected.- Inferred entities are registered with
EnsureQueue/EnsureTopic. An explicitAddQueue/AddTopicfor the same message no longer throws "already registered" — it simply wins. AddTopicSubscriberoverloads were collapsed:(int subscriberCount = 1, Action? configure = null)and(Action configure, int subscriberCount = 1).IMessagenow exposesIdandSource. Types deriving fromMessage<TPayload>already satisfy it; hand-rolled implementations must add the two properties.MessageSerializer.Serializereturns aPooledMessageBodythat must be disposed; useSerializeToArrayfor the previousbyte[]behaviour.ICreatableRegistration(and the RabbitMQ/in-memory counterparts) gainedstatic abstract RetryPolicy? GetRetryPolicy(). Generated subscribers and every subscriber implementingISubscriber<,>/IRabbitMqSubscriber<,>/IInMemorySubscriber<,>get anulldefault for free.- Azure Service Bus queue processors now honour
[SubscriberCount](and the newAddQueueSubscriber(int subscriberCount)parameter) and setPrefetchCount = MaxConcurrentCalls. Queue subscribers that were relying on the implicitMaxConcurrentCalls = 1and declared a higher count will now run concurrently. InMemoryMessageDispatchertakes(IServiceScopeFactory, InMemoryConfiguration, ILogger?); it is registered byAddInMemory, 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.mdlists the rules added since the last released version. New ids go here, under### New Rules.AnalyzerReleases.Shipped.mdis the history of published releases. At release time the content of the unshipped file is moved under a new## Release X.Yheading 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
Add or update the pending rules in
AnalyzerReleases.Unshipped.mdif the release introduces new diagnostics.Promote them and commit the result:
pwsh ./scripts/promote-analyzer-release.ps1 -Version 1.0 git commit -am "Prepare release 1.0"Publish a GitHub release whose tag is the package version, optionally prefixed with
v(v1.0.0and1.0.0both produce version1.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 | Versions 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. |
-
net10.0
- NetPub.AzureServiceBus (>= 1.2.0)
- NetPub.Contracts (>= 1.2.0)
- NetPub.InMemory (>= 1.2.0)
- NetPub.Publishing (>= 1.2.0)
- NetPub.RabbitMq (>= 1.2.0)
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 |