This document covers building robust, scalable, and resilient applications using messaging patterns in the .NET ecosystem. Touches upon foundational concepts and advanced, real-world implementation and operational strategies.
The fundamental problem that messaging solves is decoupling.
- Asynchronous Communication: Allows services to communicate without waiting for a direct, synchronous response. This improves responsiveness and user experience.
- Resilience & Fault Tolerance: If a consumer service is down, messages can wait safely in a queue until the service is available again, preventing data loss.
- Scalability & Load Leveling: A message queue acts as a buffer, absorbing spikes in traffic. You can scale the number of consumers (workers) independently of the publisher (API).
- Service Autonomy: The message publisher doesn't need to know who is listening. New services can subscribe to events without requiring any changes to the original publisher.
We identified two distinct levels of messaging, each with its own primary tool.
Feature | MediatR (In-Process) π¦ | Azure Service Bus / RabbitMQ (Distributed) π |
---|---|---|
Scope | Within a single application process. | Between different services, across a network. |
Use Case | Clean internal architecture, CQRS. | Asynchronous background jobs, microservices communication. |
Persistence | None. Messages are lost on app restart. | Yes. Messages are durably stored in a message broker. |
Key Pattern | IRequest (Command/Query), INotification (Pub/Sub) |
Competing Consumers (Queues), Pub/Sub (Topics) |
π‘ Key Insight: They are not competitors; they are complementary. Use MediatR for a clean internal architecture and a durable broker like Azure Service Bus for resilient inter-service communication.
When you need a durable, out-of-process broker, two main options stand out.
-
Azure Service Bus (ASB) βοΈ
- Type: Fully managed PaaS (Platform as a Service).
- Pros: β Zero management overhead, seamless Azure integration (identity, monitoring), built-in disaster recovery.
- Cons: β Vendor lock-in, less routing flexibility than RabbitMQ.
- Best For: Teams building Azure-native applications who want to focus on business logic, not infrastructure management.
-
RabbitMQ π
- Type: Open-source message broker software (self-hosted or managed).
- Pros: β Runs anywhere (multi-cloud/on-prem), extremely flexible routing via exchanges, powerful management UI.
- Cons: β You are responsible for hosting, clustering, patching, and monitoring (high operational overhead).
- Best For: Multi-cloud environments, complex routing needs, or teams with existing operational expertise.
These frameworks sit on top of a broker to enforce patterns and dramatically improve developer productivity.
-
NServiceBus πΌ
- Philosophy: Commercial, opinionated, and prescriptive. Creates a "pit of success."
- Pros: β World-class commercial tooling (ServiceInsight, Pulse), professional support with an SLA.
- Cons: β Requires a paid license, less flexible than MassTransit.
-
MassTransit π
- Philosophy: Free and open-source (FOSS), flexible, and powerful. A comprehensive toolkit.
- Pros: β No cost, extremely powerful State Machine Sagas, ultimate flexibility.
- Cons: β Community-only support, requires you to "bring your own" observability stack (OpenTelemetry, Grafana, etc.).
We built a complete solution to demonstrate the Competing Consumers pattern. This involved a Web API publisher and a Worker Service with two consumers.
π¨ The Troubleshooting Journey - Key Learnings:
-
Problem:
bus.Send()
fails withA convention for the message type... was not found
.- π‘ Solution: The publisher must be told where to send commands. This is done by configuring a global, static mapping:
EndpointConvention.Map<MyCommand>(new Uri("queue:my-queue-name"));
- π‘ Solution: The publisher must be told where to send commands. This is done by configuring a global, static mapping:
-
Problem: Messages are sent but consumers don't receive them.
- π‘ Solution: The consumer's endpoint name must match the publisher's convention. We configured the consumer explicitly:
cfg.ReceiveEndpoint("my-queue-name", e => ...);
- π‘ Solution: The consumer's endpoint name must match the publisher's convention. We configured the consumer explicitly:
-
Problem: Both consumers received a copy of every message, even when using
bus.Send()
.- π‘ Solution: This was a subtle issue caused by a stale broker state from previous test runs. The key to developing with messaging is to always ensure a clean broker state. Delete application-specific queues and exchanges in the RabbitMQ UI before starting a fresh debugging session. The modern MassTransit v8+ default topology (a fanout exchange bound to a queue of the same name) correctly implements competing consumers.
For long-running, multi-step processes (like a file ingestion pipeline), we discussed two primary approaches:
-
Saga (Orchestration) π§β
βοΈ :- A central "orchestrator" state machine that sends commands to workers and reacts to their events.
- Pros: High visibility of the workflow logic, centralized error handling.
- Recommended For: Well-defined, business-critical processes.
-
Routing Slip (Choreography) π:
- An "itinerary" is attached to the message itself, which self-routes from one worker to the next.
- Pros: Highly decoupled; no central point of failure.
- Cons: Poor visibility into the overall state of the process.
We explored strategies for modifying a live Saga workflow without downtime, using a Feature Flag system (like Azure App Configuration) as the recommended approach.
-
To Pause/Suspend a Step:
- The Saga checks a flag (
IsStepXEnabled
). - If
false
, the Saga transitions to a dedicatedStepXSuspended
state instead of sending the next command. - A separate process is needed to "wake up" suspended sagas once the flag is re-enabled.
- The Saga checks a flag (
-
To Skip a Step Entirely:
- The Saga checks a flag (
IsStepXSkipped
). - If
true
, the Saga logs the skip for auditing. - It then immediately sends the command for the following step (e.g., sends the Step 4 command instead of the Step 3 command) and transitions directly to the following state.
- π¨ Caution: Downstream consumers must be resilient to potentially missing data from the skipped step.
- The Saga checks a flag (
Of course. Here is the summary of our discussion on authentication and authorization, formatted and ready to be appended as point #8 to the Markdown document.
Handling user identity in an asynchronous, distributed messaging system is a critical security challenge. Passing JWTs in messages is an anti-pattern that introduces significant security, performance, and reliability issues.
π¨ The Anti-Pattern: Embedding JWTs in Messages
- Performance π: JWTs add significant size (1-2KB+) to every message, increasing network, storage, and CPU overhead at scale.
- Security π‘οΈ: A JWT is a bearer token. If a message is ever compromised, the token can be stolen and used in replay attacks to impersonate the user.
- Reliability β³: JWTs have short expiry times. For any long-running or delayed process, the token will be expired by the time the consumer processes the message, breaking the workflow.
β The Solution: The Trusted Subsystem Model
The correct approach is to shift from authenticating the user at every step to authenticating the service at the infrastructure level.
Secure the "pipes" between your services and the message broker. The broker acts as the bouncer, ensuring only trusted applications can connect.
- How: Use strong, infrastructure-native authentication mechanisms.
- With Azure Service Bus: Use Managed Identity for passwordless, secret-free authentication between your Azure-hosted services and the broker.
- With RabbitMQ: Use TLS with client certificates (mTLS) or securely managed credentials.
- Outcome: Any consumer connected to the broker is now considered part of a trusted subsystem.
Once a service is trusted, it doesn't need proof of identity (the JWT); it just needs the identity's essential details.
-
How: The API, as the public gateway, is the only service that validates the inbound JWT. After validation, it extracts the necessary, non-sensitive claims and copies them into the message.
-
The Message Contract: The message should carry the who and what, not the proof.
// The JWT is NOT here. Only the necessary, validated data is. public record SubmitOrder( Guid OrderId, string CustomerNumber, // --- Identity Context --- Guid UserId, // The 'sub' claim from the JWT Guid TenantId, // The 'tenant_id' claim string CorrelationId // For end-to-end tracing );
-
Outcome: The consumer implicitly trusts the
UserId
andTenantId
in the message because it came over a secure channel from a trusted publisher. This completely solves the token expiry problem for long-running processes like Sagas.