Syed.Messaging.Sagas.EfCore
1.3.0
dotnet add package Syed.Messaging.Sagas.EfCore --version 1.3.0
NuGet\Install-Package Syed.Messaging.Sagas.EfCore -Version 1.3.0
<PackageReference Include="Syed.Messaging.Sagas.EfCore" Version="1.3.0" />
<PackageVersion Include="Syed.Messaging.Sagas.EfCore" Version="1.3.0" />
<PackageReference Include="Syed.Messaging.Sagas.EfCore" />
paket add Syed.Messaging.Sagas.EfCore --version 1.3.0
#r "nuget: Syed.Messaging.Sagas.EfCore, 1.3.0"
#:package Syed.Messaging.Sagas.EfCore@1.3.0
#addin nuget:?package=Syed.Messaging.Sagas.EfCore&version=1.3.0
#tool nuget:?package=Syed.Messaging.Sagas.EfCore&version=1.3.0
Syed.Messaging
MIT-licensed, transport-agnostic .NET messaging. Outbox, sagas, OTel, and DLQ-driven autoscaling out of the box.
What it is
Syed.Messaging is an MIT-licensed, transport-agnostic .NET messaging framework with one API across RabbitMQ, Kafka, and Azure Service Bus. The operational stack β retry, DLQ, outbox, inbox, sagas, OpenTelemetry, Prometheus metrics, KEDA autoscaling β is built in, not bolted on.
- π One API, three transports. RabbitMQ, Kafka, Azure Service Bus. Swap transport without changing handler code.
- π Observability-first. OpenTelemetry traces + 7 counters + 1 histogram + DLQ dashboard + autoscaling playbook ship with the library.
- π§° Production patterns built in. Outbox, inbox, sagas, retry + DLQ, middleware pipeline, RPC, health checks.
- β‘ Quick to hello-world.
docker compose up -dthendotnet runβ see Quick Start.
Coming from MassTransit? The migration guide at docs/migrating-from-masstransit.md covers consumer registration, sagas, outbox, retry, and middleware side-by-side.
// Register everything in one fluent chain
services.AddMessaging(builder =>
{
builder.UseRabbitMq(o => o.ConnectionString = "amqp://localhost");
builder.AddMiddleware<TenantContextMiddleware>();
builder.AddConsumer<OrderCreated, OrderCreatedHandler>(o =>
{
o.Destination = "orders.created";
o.SubscriptionName = "orders.consumer";
o.RetryPolicy = new RetryPolicy { MaxRetries = 3, Backoff = RetryBackoff.Exponential };
o.MaxConcurrency = 4;
});
});
π DLQ-driven autoscaling, with a documented signal model
Most messaging libraries ship metrics. Few ship the playbook for what to do with them.
Syed.Messaging emits a Prometheus-ready meter (Syed.Messaging) with 7 counters and a processing-duration histogram. The DLQ counter carries transport, destination, message_type, and reason tags (destination is normalized to keep cardinality bounded). The repo ships:
- π DLQ dashboard + alerts β docs/observability/dlq-dashboard.md
- π Autoscaling signal model β retry pressure, throughput headroom, retry-to-DLQ conversion, poison ratio β docs/observability/autoscaling-signals.md
- βΈοΈ KEDA + HPA reference manifests β docs/deploy/kubernetes/
Block scale-up when conversion ratio spikes (broken pipeline). Scale on retry pressure (real load). Don't burn replicas on poison messages. The decision flow is documented.
β¨ Features
| Feature | Description |
|---|---|
| Transport-agnostic | RabbitMQ, Kafka, Azure Service Bus β same API |
| Typed consumers | IMessageHandler<T> with automatic deserialization |
| Middleware pipeline | IMessageMiddleware for cross-cutting concerns (v1.1.0) |
| Chaos engineering | Inject realistic failures (drop, duplicate, delay, ack-timeout) in dev β on by default, refused in prod (v1.3.0) |
| Retry + DLQ | Configurable retry policies with exponential backoff and dead-letter routing |
| Outbox pattern | EF Core-based transactional outbox with raw mode support |
| Inbox deduplication | Idempotent consumer pattern via EF Core |
| Saga orchestration | State management, correlation, timeouts, distributed locking |
| RPC support | Request/response messaging with IRpcHandler<TReq, TRes> |
| Observability | OpenTelemetry spans + 7 counters + 1 histogram on the Syed.Messaging meter |
| Autoscaling playbook | DLQ + retry signal model with KEDA / HPA reference manifests |
| Per-destination queues (RabbitMQ) | Each consumer gets its own queue β no cross-talk (v1.2.0) |
| Health checks | ASP.NET Core health check integration per transport |
| SignalR bridge | Route messaging events to SignalR hubs |
| Service discovery | Kubernetes DNS, Consul, standard DNS |
| Message versioning | VersionedMessage<T>, schema registry, compatibility rules |
π¦ Packages
| Package | Description |
|---|---|
Syed.Messaging.Abstractions |
Core interfaces: IMessageBus, IMessageHandler<T>, IMessageMiddleware, IMessageTransport |
Syed.Messaging.Core |
GenericMessageConsumer<T>, RpcMessageConsumer, MessagingBuilder, retry/DLQ logic |
Syed.Messaging.RabbitMq |
RabbitMQ transport with topology builder, publisher confirms, per-destination queues |
Syed.Messaging.Kafka |
Kafka transport with topic-based retry/DLQ |
Syed.Messaging.AzureServiceBus |
Azure Service Bus transport with scheduled message retry |
Syed.Messaging.Outbox.EfCore |
Transactional outbox with OutboxPublisherService and raw mode |
Syed.Messaging.Inbox.EfCore |
Idempotent consumer inbox pattern |
Syed.Messaging.Sagas |
Saga primitives: state, correlation, timeouts, locking |
Syed.Messaging.Sagas.EfCore |
EF Core persistence for saga state and timeouts |
Syed.Messaging.Sagas.Redis |
Redis distributed saga locking |
Syed.Messaging.OpenTelemetry |
Activity spans for publish/consume and trace context propagation |
Syed.Messaging.HealthChecks |
ASP.NET Core health check integration |
Syed.Messaging.Chaos |
Chaos-engineering middleware β inject failures in dev, refused in prod (v1.3.0) |
Syed.Messaging.SignalR |
Bridge messaging events to SignalR hubs |
Syed.Messaging.Aspire |
.NET Aspire integration helpers |
Syed.BuildingBlocks |
Shared utilities and feature flags |
Installation
The publish workflow (.github/workflows/publish.yml) pushes packages to NuGet.org (when the NUGET_API_KEY secret is set on the release) and GitHub Packages (always). For most users, NuGet.org is the easier path:
dotnet add package Syed.Messaging.Core --version 1.2.2
dotnet add package Syed.Messaging.RabbitMq --version 1.2.2
If a specific version isn't on NuGet.org yet, it's available on GitHub Packages. Add that feed with a personal access token scoped to
read:packages:# Read PAT from env or prompt β avoids writing it into shell history. # On non-Windows, NuGet.config stores the password in plaintext; # consider using `dotnet user-secrets` for a more secure local-dev pattern. read -rs -p "GitHub PAT (read:packages scope): " GH_PAT && echo dotnet nuget add source https://nuget.pkg.github.com/moshiur/index.json \ --name syed-messaging \ --username "$GITHUB_USERNAME" \ --password "$GH_PAT"
π Quick Start
Prerequisites: Docker (for the broker stack) + .NET 10 preview SDK.
1. Clone and start the broker stack
git clone https://github.com/moshiur/Syed.Messaging.git
cd Syed.Messaging
docker compose up -d # RabbitMQ + Kafka + Zookeeper (bound to 127.0.0.1)
docker-compose.yml exposes RabbitMQ on localhost:5672 (management UI on localhost:15672) and Kafka on localhost:9092. Loopback-only by design β see the security note in that file. Azure Service Bus needs a real namespace; see samples/ServiceBusWorker/.
2. Run the OrderWorker sample
dotnet run --project samples/OrderWorker/OrderWorker.csproj
Within ~30 seconds you should see the handler log line:
info: OrderCreatedHandler[0]
Worker received OrderCreated: OrderId=<guid>, CustomerId=customer-123, Retry=0
The sample publishes a test event on startup and consumes it via OrderCreatedHandler (Program.cs, OrderCreatedHandler.cs). It's the canonical "hello world" β read those two files to see the working shape.
3. Build your own (the same shape)
Define a message and handler:
[MessageType("orders.created")]
public record OrderCreated(Guid OrderId, string CustomerId);
public class OrderCreatedHandler : IMessageHandler<OrderCreated>
{
private readonly ILogger<OrderCreatedHandler> _logger;
public OrderCreatedHandler(ILogger<OrderCreatedHandler> logger) => _logger = logger;
public Task HandleAsync(OrderCreated message, MessageContext ctx, CancellationToken ct)
{
_logger.LogInformation("Got order {OrderId} for {CustomerId}", message.OrderId, message.CustomerId);
return Task.CompletedTask;
}
}
Wire it up:
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddMessaging(m =>
{
m.UseRabbitMq(o =>
{
o.ConnectionString = "amqp://guest:guest@localhost:5672/";
o.MainExchangeName = "orders.exchange";
});
m.AddConsumer<OrderCreated, OrderCreatedHandler>(c =>
{
c.Destination = "orders.created";
c.SubscriptionName = "orders.consumer";
});
});
var app = builder.Build();
// Publish from any DI scope β e.g. on startup or from an ASP.NET Core controller.
using (var scope = app.Services.CreateScope())
{
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
await bus.PublishAsync("orders.created", new OrderCreated(Guid.NewGuid(), "cust-123"));
}
await app.RunAsync();
π Middleware Pipeline
Middleware runs before every handler β ideal for tenant context, logging, auth propagation:
public class TenantContextMiddleware : IMessageMiddleware
{
public async Task InvokeAsync(IMessageEnvelope envelope, IServiceProvider sp, Func<Task> next)
{
if (envelope.Headers.TryGetValue("tenant-id", out var tenantId))
{
var ctx = sp.GetRequiredService<ITenantContext>();
ctx.SetTenant(tenantId);
}
await next(); // handler runs with tenant context set
}
}
// Register
services.AddMessaging(m => m.AddMiddleware<TenantContextMiddleware>());
Middlewares execute in registration order (first registered = outermost wrapper).
πͺ Chaos Engineering (v1.3.0)
Your handlers only see the happy path in dev β that's why production incidents surprise you. Syed.Messaging.Chaos injects realistic failures into consumed messages so the bugs surface where they're cheap to fix.
services.AddMessaging(m =>
{
m.UseRabbitMq(o => o.ConnectionString = "amqp://localhost");
m.AddConsumer<OrderCreated, OrderCreatedHandler>(c => c.Destination = "orders.created");
m.EnableChaos(); // off until SYED_CHAOS_LEVEL is set; refused in Production by default
});
SYED_CHAOS_LEVEL=medium dotnet run
warn: [CHAOS:duplicate] Invoking the handler for orders.created TWICE to test idempotency.
If your handler isn't safe to call twice, that's a real bug production will eventually hit.
Five shapes ship in v1.3.0: Drop, Duplicate, Delay, HeaderCorruption (additive), AckTimeout. Duplicate auto-skips when an IInboxStore is registered. Chaos refuses to run in Production unless you explicitly opt in (SYED_CHAOS_PROD=true), and emits a separate Syed.Messaging.Chaos meter so it never pollutes your SRE dashboards.
See it broker-free in ~5 seconds: samples/ChaosDemo. Full shape-safety matrix + config reference: src/Syed.Messaging.Chaos.
π RabbitMQ Transport
Per-destination queue routing (v1.2.0):
Publisher ββrouting keyβββΊ Main Exchange (Direct)
β
βββββββββββΌβββββββββββ
βΌ βΌ βΌ
orders.queue billing.queue notifications.queue
β
βΌ (on failure)
Retry Exchange βββΊ Retry Queue (TTL) ββDLXβββΊ Main Exchange
(preserves routing key)
- Each
AddConsumer<T>()auto-declares its own queue bound by destination - Retry queue preserves original routing key on DLX β messages return to the correct queue
- DLQ captures poison messages with diagnostic headers (
x-poison-*)
π§΅ Kafka Partition Strategy
Kafka message ordering is partition-scoped, not topic-scoped. To preserve ordering for a business entity, publish a stable partition-key.
await bus.PublishRawAsync(
"orders.created",
JsonSerializer.SerializeToUtf8Bytes(new OrderCreated(orderId, customerId)),
"OrderCreated",
new Dictionary<string, string> { ["partition-key"] = customerId });
Practical guidance
- Use aggregate IDs (
CustomerId,OrderId,TenantId) aspartition-key. - Same key means same partition, which gives deterministic in-order handling for that key.
- Different keys can run in parallel with:
services.AddMessaging(m =>
{
m.UseKafka(k =>
{
k.Consumer.MaxConcurrentPartitions = 4;
k.Consumer.PartitionAssignmentStrategy = KafkaPartitionAssignmentStrategy.CooperativeSticky;
});
});
This gives you the common production shape: strict ordering per entity, concurrency across entities.
π€ Outbox Pattern
Guarantee at-least-once delivery with a transactional outbox. The DB write and the outbox row commit in the same EF Core transaction because both Add calls accumulate in the same DbContext and EfCoreOutboxStore.SaveAsync commits everything in one SaveChangesAsync:
dbContext.Orders.Add(order);
// EfCoreOutboxStore.SaveAsync internally calls dbContext.SaveChangesAsync(),
// which commits BOTH the staged Order and the new OutboxMessage in one
// transaction. Do not call SaveChangesAsync again afterward.
await outbox.SaveAsync(new OutboxMessage
{
Id = Guid.NewGuid(),
Destination = "orders.created",
MessageType = "orders.created", // stable wire key (see [MessageType] convention)
Payload = JsonSerializer.SerializeToUtf8Bytes(order),
CreatedAtUtc = DateTimeOffset.UtcNow,
}, ct);
// OutboxPublisherService polls every 5s and republishes via IMessageBus.
services.AddHostedService<OutboxPublisherService>();
Supports raw mode (UseRawMode = true on OutboxPublisherService) for anonymous payloads when the producing and consuming services don't share CLR types.
PII / encryption note: outbox rows are stored in your application database as
byte[]payloads, unencrypted. For PII / PHI / payment data, apply column-level encryption (TDE / Always Encrypted / pgcrypto) and configure retention/purge on processed rows.
β» Saga Orchestration
Long-running workflows with state management and distributed locking. Sagas are plain classes that implement ISagaHandler<TSagaState, TMessage>. Sending happens via the injected IMessageBus; timeouts via the injected ISagaTimeoutScheduler.
public class OrderSagaState : ISagaState
{
public Guid Id { get; set; }
public int Version { get; set; }
public Guid OrderId { get; set; }
public string CurrentState { get; set; } = "AwaitingInventory";
}
public class OrderSaga : ISagaHandler<OrderSagaState, OrderCreated>
{
private readonly IMessageBus _bus;
private readonly ISagaTimeoutScheduler _timeouts;
public OrderSaga(IMessageBus bus, ISagaTimeoutScheduler timeouts)
{
_bus = bus;
_timeouts = timeouts;
}
public async Task HandleAsync(OrderSagaState state, OrderCreated msg, MessageContext ctx, CancellationToken ct)
{
state.OrderId = msg.OrderId;
state.CurrentState = "AwaitingInventory";
await _bus.PublishAsync("inventory.reserve", new ReserveInventory(msg.OrderId), ct);
await _timeouts.ScheduleAsync(
sagaType: typeof(OrderSaga),
correlationKey: msg.OrderId.ToString(),
delay: TimeSpan.FromMinutes(5),
timeout: new InventoryTimeout(msg.OrderId),
ct: ct);
}
}
Wiring needs a SagaMessageHandler<TMessage> consumer for each triggering message, plus an AddSagas block that declares correlation. The full worked example is in samples/OrderSagaDemo/. Persistence options: EF Core (Syed.Messaging.Sagas.EfCore) or in-memory. Locking: Redis (Syed.Messaging.Sagas.Redis), in-memory, or no-op.
π Observability
// OpenTelemetry tracing (publish + consume activities, W3C trace context).
services.AddOpenTelemetry()
.WithTracing(b => b.AddSource("Syed.Messaging"))
.WithMetrics(b => b.AddMeter("Syed.Messaging"));
Metrics emitted by the Syed.Messaging meter (defined in src/Syed.Messaging.Core/MessagingMetrics.cs) β 7 counters plus a processing-duration histogram:
messaging.messages.publishedmessaging.messages.receivedmessaging.messages.processedmessaging.messages.failedmessaging.messages.retriedmessaging.messages.deadlettered(richest tagging:transport,destination,message_type,reason; destination normalized)messaging.messages.poisonedmessaging.messages.processing_duration(histogram, ms)
The convenience extension from Syed.Messaging.OpenTelemetry is equivalent to the tracing block above:
services.AddOpenTelemetry()
.WithTracing(t => t.AddSyedMessagingInstrumentation());
π§ͺ Testing
A broker-free unit test suite across 7 test projects (run dotnet test for the current count). Broker-touching tests are skipped when the relevant env var is unset; the CI workflow at .github/workflows/publish.yml spins up Kafka + Zookeeper as service containers.
dotnet test Syed.Messaging.sln -c Release
No in-memory test transport ships in v1.2.0 β integration tests need Testcontainers or a real local broker. An
IMessageTransportin-memory implementation is on the contribution wishlist.
π Changelog
v1.3.0 β Chaos Engineering
- New
Syed.Messaging.Chaospackage:EnableChaos()adds chaos middleware to the consumer pipeline - Five failure shapes:
Drop,Duplicate,Delay,HeaderCorruption(additive),AckTimeout SYED_CHAOS_LEVELenv var (Off/Low/Medium/High) overrides code config; defaultOff- Production-safety gate: chaos refused when
ASPNETCORE_ENVIRONMENT=ProductionunlessSYED_CHAOS_PROD=trueorProductionAllowed Duplicateauto-skips when anIInboxStoreis registered (avoids unsafe double-invocation)- Dedicated
Syed.Messaging.Chaosmeter (messaging.chaos.injected) β never touches the coremessaging.messages.failedcounter - Thread-safe, optionally-seeded injector for reproducible chaos in tests
- Broker-free
samples/ChaosDemo+ 32 unit tests
v1.2.2 β Docs accuracy + security hardening
- Soften README hero; drop adversarial framing
- Fix saga code example (real
ISagaHandler<TState, TMsg>.HandleAsyncshape, sending viaIMessageBus, timeouts viaISagaTimeoutScheduler) - Fix outbox code example (
Id,CreatedAtUtc, correct EF Core single-transaction commit shape, stable wire-key forMessageType) - Replace fabricated metric names with the real 7 counters + processing-duration histogram on the
Syed.Messagingmeter RabbitMqTransport: redactuser:passwordfrom connection-failure log lines (newRedactConnectionStringhelper)docker-compose.yml: bind every host port to127.0.0.1, setKAFKA_HEAP_OPTSfor laptop-friendly memory- Migration guide: fix MassTransit v8 (Apache 2.0) vs v9 (commercial via Massient) framing; new "Credentials, secrets, and production hardening" section
- New:
LICENSE(MIT),CLAUDE.md,docker-compose.yml,docs/migrating-from-masstransit.md,samples/ServiceBusWorker/README.md - CI: fix
secrets.*in step-levelif:expression (workflow was failing in 0s on every trigger before this release)
v1.2.1 β manual publish
- No CI release. Bits identical to v1.2.0; pushed directly via
dotnet nuget pushwhile the publish workflow was broken (fixed in v1.2.2).
v1.2.0 β Per-Destination Queue Routing
SubscribeAsyncauto-declares per-destination queues bound by routing key- Each consumer only receives its own messages β fixes shared-queue poison issue
- Retry/DLQ routing preserves original destination
MainQueueNamedeprecated
v1.1.0 β Middleware Pipeline
IMessageMiddlewareinterface for pre-handler cross-cutting concerns- Integrated into
GenericMessageConsumerandRpcMessageConsumer MessagingBuilder.AddMiddleware<T>()for fluent registration
π€ Contributing
PRs, issues, and design discussions are welcome. This is a platform-style library β architectural feedback is especially valuable.
See ROADMAP.md for planned features and current status.
π License
| 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
- Microsoft.EntityFrameworkCore (>= 10.0.8)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.8)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
- Syed.Messaging.Abstractions (>= 1.3.0)
- Syed.Messaging.Core (>= 1.3.0)
- Syed.Messaging.Sagas (>= 1.3.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.