Modern .NET applications often begin with a straightforward architecture:
Controller
→ Service
→ Database
→ Response
This works well while the application is small.
As the project grows, however, business logic starts spreading across controllers, application services, background workers, event handlers, databases, and message brokers.
Eventually, teams begin asking questions such as:
- How do we keep business logic out of controllers?
- How should commands and queries be organized?
- How can several modules respond to the same event?
- What happens when asynchronous processing fails?
- How do we prevent duplicate message processing?
- How can we introduce durable messaging without redesigning the entire application?
I created Signalynx to provide a structured answer to these problems.
Signalynx is an open-source, strongly typed mediator, CQRS dispatcher, and durable messaging toolkit for .NET 8, .NET 9, and .NET 10.
It supports:
- Commands, queries, and request-response messages
- Notifications and domain events
- Pipeline behaviors
- Bulk processing
- Durable message processing
- Retries and scheduling
- Inbox and outbox patterns
- Dead-letter handling and replay
- SQL Server and PostgreSQL persistence
- RabbitMQ, Kafka, Azure Service Bus, and Amazon SQS
- NativeAOT-friendly source generation
- OpenTelemetry-compatible diagnostics
Project links:
The problem with direct service dependencies
Consider an order endpoint that creates an order, sends a confirmation email, and reserves inventory:
public async Task<IActionResult> CreateOrder(
CreateOrderRequest request)
{
var order = await _orderService.CreateAsync(request);
await _emailService.SendConfirmationAsync(order);
await _inventoryService.ReserveItemsAsync(order);
return Ok(order);
}
This code looks reasonable at first.
But the controller is now responsible for:
- Creating the order
- Triggering email delivery
- Reserving inventory
- Deciding execution order
- Coordinating dependencies
- Handling failures
As more operations are added, the endpoint becomes tightly coupled to multiple services.
With a mediator-based design, the endpoint describes what should happen without coordinating every implementation detail:
var orderId = await signalynx.DispatchAsync<
CreateOrderCommand,
Guid>(
command,
cancellationToken);
The command handler contains the business operation.
Validation, logging, authorization, transactions, metrics, and other cross-cutting concerns can be handled through pipeline behaviors.
The endpoint becomes an entry point rather than a container for business logic.
Strongly typed commands and queries
Signalynx uses explicit contracts for different kinds of application operations.
Commands
Commands normally represent operations that change application state:
public sealed record CreateOrderCommand(
Guid CustomerId,
decimal Amount)
: ICommand<Guid>;
The handler declares both the command it accepts and the result it returns:
public sealed class CreateOrderHandler
: ICommandHandler<CreateOrderCommand, Guid>
{
public ValueTask<Guid> HandleAsync(
CreateOrderCommand command,
CancellationToken cancellationToken = default)
{
var orderId = Guid.NewGuid();
// Save the order.
return ValueTask.FromResult(orderId);
}
}
Queries
Queries represent read operations:
public sealed record GetOrderQuery(Guid OrderId)
: IQuery<OrderDto>;
Separating commands and queries makes the purpose of each operation easier to understand.
Instead of tracing generic methods such as ExecuteAsync, ProcessAsync, or HandleAsync across several services, developers can identify the application flow from the message contract itself.
Pipeline behaviors
Production applications usually need cross-cutting functionality such as:
- Validation
- Logging
- Authorization
- Metrics
- Transactions
- Auditing
- Exception handling
Without a pipeline, this logic is often repeated inside controllers and handlers.
Signalynx allows these concerns to be registered as pipeline behaviors:
builder.Services.AddSignalynx(options =>
{
options.RegisterServicesFromAssembly(
typeof(Program).Assembly);
options.AddOpenBehavior(
typeof(ValidationBehavior<,>));
options.AddOpenBehavior(
typeof(LoggingBehavior<,>));
});
The handler can then remain focused on the business operation.
This also makes architectural changes easier. For example, changing the logging or validation strategy does not require modifying every command handler.
Domain events and multiple handlers
Some business operations need to notify several parts of an application.
After an order is confirmed, the application may need to:
- Reduce inventory
- Send a confirmation notification
- Add an audit entry
- Update reporting data
- Trigger analytics
The order handler should not need direct dependencies on all of these modules.
Instead, it can publish an event:
await signalynx.PublishEventAsync(
new OrderConfirmed(orderId),
cancellationToken);
Multiple handlers can respond to the event independently.
For example:
OrderConfirmed
├── Inventory handler
├── Notification handler
├── Analytics handler
└── Audit handler
This is especially useful in modular monoliths, where business modules share one deployment but should remain logically independent.
Signalynx supports sequential and parallel publishing.
Sequential execution is generally easier to reason about because ordering and failure behavior are predictable. Parallel execution can be useful when handlers are independent and thread-safe.
Local events are not durable
In-process events are useful, but they have an important limitation.
Suppose an application publishes an event and then shuts down before every handler completes. The unfinished work may be lost.
That may be acceptable for optional local operations. It is usually not acceptable for critical processes such as:
- Payment processing
- Order fulfillment
- Security-event processing
- Customer notifications
- Financial reconciliation
- Integration with another service
When work must survive process restarts or temporary infrastructure failures, it needs durable messaging.
Signalynx Messaging adds:
- Durable message envelopes
- Hosted processing workers
- Retries
- Delayed scheduling
- Inbox and outbox contracts
- Dead-letter handling
- Message replay
A message can be enqueued for asynchronous processing:
await messageBus.EnqueueAsync(
new OrderSubmitted(orderId),
destination: "orders",
cancellationToken: cancellationToken);
It can also be scheduled for later:
await messageBus.ScheduleAsync(
new OrderSubmitted(orderId),
DateTimeOffset.UtcNow.AddMinutes(5),
destination: "orders",
cancellationToken: cancellationToken);
If processing repeatedly fails, the message can be moved to a dead-letter store for investigation and later replay.
Why the transactional outbox matters
Distributed applications often need to perform two operations:
- Save business data in a database.
- Publish an integration message.
For example:
Save Order
Publish OrderCreated
What happens when the database transaction succeeds but the message broker is unavailable?
The order exists, but the OrderCreated message is never delivered.
The system is now inconsistent.
The transactional outbox pattern addresses this problem by storing the business change and the outgoing message in the same database transaction:
Database transaction
├── Insert Order
└── Insert Outbox Message
A background worker later reads the outbox record and publishes it to the broker:
Outbox
→ Background publisher
→ Message broker
→ Consumer
If the broker is temporarily unavailable, the message remains in the outbox and can be retried.
Signalynx provides contracts and storage adapters for implementing this pattern.
However, the application must still ensure that the business write and outbox insert participate in the same database transaction.
A library can provide the infrastructure, but it cannot automatically make every business operation transactional.
Why the inbox matters
Most message brokers use at-least-once delivery.
This means the same message may be delivered more than once.
For example, a consumer might successfully process a message but fail before acknowledging it. The broker may then deliver it again.
Without duplicate protection, the application could:
- Charge a customer twice
- Send the same email several times
- Reserve inventory twice
- Create duplicate records
The inbox pattern records processed message identifiers:
Incoming Message
→ Check Inbox
→ Already processed: skip
→ New message: process and record
Signalynx provides inbox contracts and durable store adapters to support this workflow.
Even with an inbox, business handlers should still be designed with idempotency in mind, especially when they call external systems.
Start locally and add infrastructure later
One of the main design goals of Signalynx is modularity.
Applications do not need to install durable messaging infrastructure just to use the mediator.
You can begin with the core runtime:
dotnet add package Signalynx.Core
For Microsoft dependency injection:
dotnet add package Signalynx.DependencyInjection
Additional capabilities can be installed only when required:
dotnet add package Signalynx.Validation
dotnet add package Signalynx.Logging
dotnet add package Signalynx.SourceGeneration
dotnet add package Signalynx.Messaging
This allows an application to evolve gradually:
Controller
→ Command
→ Handler
Later, as requirements grow:
API
→ Command or Query
→ Pipeline
→ Handler
→ Domain Event
→ Outbox
→ Message Broker
→ Consumer
→ Inbox
→ Business Operation
The application does not need to adopt databases, durable stores, or message brokers before those capabilities are necessary.
Modular monoliths and microservices
Signalynx can be used in both modular monoliths and microservice architectures.
Modular monolith
A modular monolith might contain modules such as:
Application
├── Orders
├── Payments
├── Inventory
├── Customers
└── Notifications
Commands and queries can organize direct operations inside each module.
Notifications and domain events can communicate changes between modules without creating direct dependencies everywhere.
Microservices
Inside a microservice, the mediator organizes local application logic.
When communication crosses process boundaries, the durable messaging layer can handle:
- Persistence
- Retries
- Scheduling
- Broker delivery
- Duplicate detection
- Dead letters
- Replay
Signalynx includes transport integrations for:
- RabbitMQ
- Apache Kafka
- Azure Service Bus
- Amazon SQS
Durable store integrations are available for:
- SQL Server
- PostgreSQL
The result is a related programming model for both local dispatch and asynchronous communication.
NativeAOT and source generation
Runtime assembly scanning is convenient, but it can create problems for trimming and NativeAOT applications.
Signalynx supports source-generated handler registration.
The source generator can create:
- Dependency-injection registrations
- Handler metadata
- A static handler map
This avoids runtime assembly scanning on the NativeAOT path.
Traditional applications can continue using runtime discovery, while trimmed or NativeAOT applications can use generated registration.
Performance considerations
Signalynx is designed to keep mediator overhead low.
Its performance-oriented design includes:
- Typed handler calls
- Startup-time handler discovery
- Immutable handler metadata
-
ValueTask-based asynchronous contracts - Cached dispatch delegates
- Fast paths when no pipeline behaviors are registered
- No LINQ in core dispatch loops
- Optional source-generated registration
The repository includes BenchmarkDotNet benchmarks covering mediator and messaging operations.
However, isolated mediator benchmarks should not be treated as complete application benchmarks.
Real-world performance also depends on:
- Database access
- Network latency
- Message size
- Serialization
- Broker configuration
- Durability settings
- Batching
- Database indexes
- Business-handler logic
Teams should benchmark their actual workload rather than choosing an architecture solely from isolated dispatch numbers.
Observability
A messaging system should not become a black box.
Signalynx exposes activities and metrics for mediator dispatch and durable messaging, including:
- Dispatch duration and failures
- Publish duration and failures
- Messages enqueued
- Messages sent
- Messages handled
- Retry counts
- Dead-letter counts
- Handler duration
These signals can be connected to OpenTelemetry-compatible monitoring systems.
In production, teams should monitor:
- Outbox depth and message age
- Retry rate
- Dead-letter growth
- Handler latency
- Consumer lag
- Broker availability
- Processing failures
This helps detect failed integrations and growing queues before they affect customers.
When should you use Signalynx?
Signalynx becomes useful when:
- Controllers contain too much business logic.
- Services have many direct dependencies.
- The application uses CQRS.
- Multiple handlers need to respond to an event.
- Validation and logging are repeated throughout the codebase.
- Background work must survive application restarts.
- Failed operations need controlled retries.
- Duplicate messages must be detected.
- The system needs inbox or outbox patterns.
- The architecture may later introduce a message broker.
- NativeAOT support is important.
- Dispatch overhead and allocations need to remain low.
The important question is not:
Should every .NET application use a mediator?
A better question is:
Has the application become complex enough that separating requests from their handlers will improve maintainability?
When might it be unnecessary?
Signalynx is not required for every project.
A direct architecture may be better when:
- The application is very small.
- There are only a few simple operations.
- Direct service calls remain clear.
- The team does not use CQRS or event-driven patterns.
- Another mediator or messaging framework already works well.
- Adding another abstraction would create more complexity than value.
A mediator should improve the architecture. It should not become architecture for its own sake.
Installing a messaging package also does not automatically make an application production-ready.
Reliability still depends on:
- Transaction design
- Handler idempotency
- Retry classification
- Persistent storage
- Broker configuration
- Security controls
- Monitoring and alerts
- Backup and recovery
- Load testing
- Failure testing
Getting started
Install the dependency-injection integration:
dotnet add package Signalynx.DependencyInjection
Define a command:
public sealed record CreateOrderCommand(
Guid CustomerId,
decimal Amount)
: ICommand<Guid>;
Create its handler:
public sealed class CreateOrderHandler
: ICommandHandler<CreateOrderCommand, Guid>
{
public ValueTask<Guid> HandleAsync(
CreateOrderCommand command,
CancellationToken cancellationToken = default)
{
return ValueTask.FromResult(Guid.NewGuid());
}
}
Register Signalynx:
builder.Services.AddSignalynx(options =>
{
options.RegisterServicesFromAssembly(
typeof(Program).Assembly);
});
Dispatch the command:
var orderId = await signalynx.DispatchAsync<
CreateOrderCommand,
Guid>(
command,
cancellationToken);
From there, you can introduce pipeline behaviors, domain events, durable messaging, database stores, or broker transports only when your application requires them.
Final thoughts
Signalynx is designed for .NET teams that need more than a basic mediator but do not want durable messaging infrastructure to be mandatory from the beginning.
Its goal is to provide a gradual path from simple in-process dispatch to reliable distributed processing.
You can begin with commands, queries, and handlers.
As the architecture grows, you can add:
- Pipeline behaviors
- Domain events
- Persistent queues
- Transactional outbox processing
- Inbox deduplication
- Retries and scheduling
- Dead-letter handling
- Message brokers
- Production observability
Signalynx is open source under the MIT License.
Feedback, testing, contributions, and real-world usage are welcome.
What reliability or architecture problem has been the hardest part of introducing messaging into your .NET applications?
Top comments (0)