Event-Driven Architecture in .NET — Why, How, Real Code (ASP.NET Core + React), Production Tuning, and When NOT to Use It
Event-driven architecture in .NET: why choose it, problems solved, challenges, a full diagram, real ASP.NET Core + React code, and when NOT to use it.
- Author
- Randhir Jassal
- Published
- Reading time
- 34 min read
- Views
- 2 views
Event-Driven Architecture in .NET — Why, How, Real Code (ASP.NET Core + React), Production Tuning, and When NOT to Use It
Most teams reach for event-driven architecture (EDA) because they heard it "scales." Then they discover the hard part: events arrive out of order, get delivered twice, and a bug three services away surfaces as a customer complaint with no stack trace. EDA is powerful — and it trades one set of problems for another.
This guide is the honest, practical deep-dive. We cover what EDA is and why you''d choose it, the problems it actually solves, a full architecture diagram, real working code (ASP.NET Core producers/consumers + a React frontend getting live updates), the challenges you will hit, a step-by-step production-tuning playbook with the reasoning behind each lever, why it beats request/response for the right problems — and, crucially, when you should not use it at all.
TL;DR
- EDA = services communicate by emitting and reacting to events, through a broker, instead of calling each other directly. "OrderPlaced" happens; whoever cares reacts. The producer doesn''t know or wait for the consumers.
- It solves: tight coupling, blocking latency, poor scalability, and "add a new feature = change five services." Services evolve independently.
- It costs: eventual consistency, duplicate/out-of-order delivery, harder debugging, and operational complexity (a broker you must run and monitor).
- The stack: ASP.NET Core services + a broker (Azure Service Bus / RabbitMQ / Kafka) + MassTransit for the .NET abstraction + the Outbox pattern for reliability + SignalR to push live updates to a React frontend.
- Production perf comes from: the Outbox (no lost events), idempotent consumers (safe retries), partitioning + prefetch + concurrency (throughput), dead-letter queues (poison-message isolation), and end-to-end tracing (or you''re blind).
- Use it for: decoupled microservices, async workflows, real-time fan-out, audit trails, spiky load. Don''t use it for: simple CRUD apps, strong-consistency transactions, or small teams who''d drown in the operational overhead.
1. What event-driven architecture actually is
In a request/response (synchronous) system, service A calls service B and waits:
OrderService → (HTTP, blocks) → PaymentService → (HTTP, blocks) → InventoryService
If any link is slow or down, the whole chain stalls. A is coupled to B is coupled to C.
In an event-driven system, service A emits a fact — an event — and moves on. Anyone interested reacts independently:
OrderService → emits "OrderPlaced" → [broker] → PaymentService reacts
→ InventoryService reacts
→ EmailService reacts
→ AnalyticsService reacts
The producer (OrderService) doesn''t know who''s listening, doesn''t wait, and doesn''t break if a consumer is down (the event waits in the broker). Three core concepts:
- Event — an immutable record of something that happened, in the past tense:
OrderPlaced,PaymentCaptured,InventoryReserved. Not a command ("do this") — a fact ("this happened"). - Producer / publisher — emits events. Doesn''t care who consumes them.
- Consumer / subscriber — reacts to events. Doesn''t care who produced them.
- Broker — the middleware (Kafka, RabbitMQ, Azure Service Bus) that durably stores and routes events between them.
That decoupling — producers and consumers that don''t know about each other — is the whole point. Everything good and everything hard about EDA flows from it.
2. Why choose EDA — the problems it solves
2.1 Problem: tight coupling
In synchronous systems, adding a feature means editing existing services. Want to send a Slack alert when an order is placed? You edit OrderService to call SlackService. Now OrderService knows about Slack. Multiply by every cross-cutting concern and you get a tangle.
EDA fix: SlackService subscribes to OrderPlaced. OrderService never changes. New consumers are added, not integrated. This is the open/closed principle at the architecture level.
2.2 Problem: blocking latency
A synchronous checkout that calls payment → inventory → email → analytics in sequence is as slow as the sum of all of them, and fails if any one fails.
EDA fix: the checkout emits OrderPlaced and returns immediately (tens of ms). Payment, inventory, email, and analytics all process in parallel, asynchronously.
2.3 Problem: scalability under spiky load
A flash sale sends 10× traffic. In a synchronous system, the slowest downstream service becomes the bottleneck and cascading timeouts take everything down.
EDA fix: events queue in the broker. Consumers process at their own pace; you scale the bottleneck consumer independently. The broker absorbs the spike (load leveling) so nothing falls over.
2.4 Problem: independent evolution & deployment
Teams that share synchronous APIs must coordinate deployments. EDA lets each service own its events and deploy on its own schedule, as long as the event contract is stable.
2.5 Bonus: a free audit log
Because events are immutable facts, the event stream is a complete history of what happened — invaluable for auditing, debugging, replay, and analytics.
3. The full architecture
A complete e-commerce order flow — ASP.NET Core services, a broker, and a React frontend getting live updates.
┌──────────────────────────┐
React frontend ◄──SignalR────►│ API Gateway / BFF │
(live order status) │ (ASP.NET Core) │
│ POST /orders └────────────┬──────────────┘
▼ │ command
┌─────────────────┐ publishes OrderPlaced │
│ OrderService │ ───────────────┐ │
│ (ASP.NET Core) │ │ │
│ + Outbox table │ ▼ │
└─────────────────┘ ┌───────────────────────┐
│ │ MESSAGE BROKER │
│ writes order+event │ (Azure Service Bus / │
│ in ONE transaction │ RabbitMQ / Kafka) │
▼ │ topics: orders.* │
┌─────────┐ └───────┬───────┬────────┘
│ Orders │ │ │ │
│ DB │ ┌─────────────────┘ │ └─────────────────┐
└─────────┘ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ PaymentSvc │ │ InventorySvc │ │ NotifySvc │
│ reserves $$ │ │ reserves stk │ │ email + push │
│ emits │ │ emits │ │ + SignalR push│
│ PaymentOK │ │ StockReserved│ │ to React │
└──────────────┘ └──────────────┘ └──────────────┘
│ │
└───────────┬───────────┘
▼
OrderService consumes PaymentOK + StockReserved
→ marks order Confirmed → emits OrderConfirmed
→ NotifySvc pushes "Confirmed" to the React UI via SignalR
Cross-cutting (every service):
┌────────────────────────────────────────────────────────────────────┐
│ OpenTelemetry tracing (one trace_id across all services + broker) │
│ Dead-letter queues (poison messages) · Idempotency store (Redis) │
└────────────────────────────────────────────────────────────────────┘
3.1 The request → event → response flow
- React
POST /orders→ API gateway →OrderService. OrderServicewrites the order and anOrderPlacedevent row to its DB in one transaction (the Outbox pattern — more below), then returns202 Acceptedwith an order ID immediately.- A background dispatcher reads the Outbox and publishes
OrderPlacedto the broker. PaymentServiceandInventoryServiceconsumeOrderPlacedin parallel, do their work, and emitPaymentOK/StockReserved.OrderServiceconsumes both, marks the order Confirmed, emitsOrderConfirmed.NotifyServiceconsumesOrderConfirmedand pushes a live "Confirmed" update to the React UI over SignalR.
The user saw a fast 202 at step 2; the rest happened asynchronously and surfaced as live UI updates.
4. The event-driven patterns you''ll use
| Pattern | What it is | Use when |
|---|---|---|
| Event notification | Thin event ("OrderPlaced", just an ID); consumers fetch details if needed | Loose coupling, small events |
| Event-carried state transfer | Event carries all the data consumers need (no callback) | Avoid chatty callbacks; consumers can be fully autonomous |
| Pub/Sub | One event, many independent subscribers | Fan-out (notify N systems of one fact) |
| Event sourcing | Store the events as the source of truth; rebuild state by replaying | Audit, time-travel, complex domains |
| CQRS | Separate write model (commands) from read model (queries), synced via events | Read/write asymmetry, high read scale |
Most systems start with event notification + pub/sub and add event sourcing / CQRS only where the domain demands it. Don''t reach for event sourcing on day one — it''s powerful and expensive.
5. Real project — the code
We''ll use MassTransit (the de-facto .NET messaging abstraction) over Azure Service Bus (swap the transport line for RabbitMQ/Kafka — the rest is identical). Plus the Outbox for reliability and SignalR for the React live updates.
5.1 The event contracts (shared)
// Contracts/Events.cs — shared between producer and consumers
namespace Shop.Contracts;
public record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal Total,
IReadOnlyList<OrderLine> Lines,
DateTime OccurredAt);
public record OrderLine(Guid ProductId, int Quantity, decimal UnitPrice);
public record PaymentCaptured(Guid OrderId, string TransactionId, DateTime OccurredAt);
public record StockReserved(Guid OrderId, DateTime OccurredAt);
public record OrderConfirmed(Guid OrderId, DateTime OccurredAt);
public record OrderFailed(Guid OrderId, string Reason, DateTime OccurredAt);
Events are records (immutable), past tense, and carry a timestamp. Keep them small and versioned carefully.
5.2 OrderService — publish with the Outbox (the reliability cornerstone)
The classic bug: you save the order to the DB, then publish the event — but the process crashes between those two steps. Now you have an order with no event. The Outbox pattern fixes this by writing the event into the same database transaction as the business data, then publishing it asynchronously.
// Program.cs — OrderService
using MassTransit;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<OrderDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Orders")));
builder.Services.AddMassTransit(x =>
{
x.AddEntityFrameworkOutbox<OrderDbContext>(o =>
{
o.UsePostgres();
o.UseBusOutbox(); // publish from the outbox after commit
o.QueryDelay = TimeSpan.FromSeconds(1);
});
x.AddConsumer<PaymentCapturedConsumer>();
x.AddConsumer<StockReservedConsumer>();
x.UsingAzureServiceBus((ctx, cfg) => // swap UsingRabbitMq / UsingKafka here
{
cfg.Host(builder.Configuration.GetConnectionString("ServiceBus"));
cfg.ConfigureEndpoints(ctx);
});
});
var app = builder.Build();
// Endpoints/OrdersEndpoint.cs
app.MapPost("/orders", async (
CreateOrderRequest req,
OrderDbContext db,
IPublishEndpoint publish) =>
{
var order = new Order(req.CustomerId, req.Lines);
db.Orders.Add(order);
// Publish goes through the Outbox — written in the SAME transaction as the
// order row. No event without an order; no order without an event.
await publish.Publish(new OrderPlaced(
order.Id, order.CustomerId, order.Total,
order.Lines.Select(l => new OrderLine(l.ProductId, l.Quantity, l.UnitPrice)).ToList(),
DateTime.UtcNow));
await db.SaveChangesAsync(); // ONE commit: order + outbox event
return Results.Accepted($"/orders/{order.Id}", new { orderId = order.Id, status = "Pending" });
});
The 202 Accepted returns in milliseconds. Payment/inventory haven''t run yet — and that''s the point.
5.3 A consumer — idempotent and resilient
// Consumers/OrderPlacedConsumer.cs — in PaymentService
using MassTransit;
public class OrderPlacedConsumer : IConsumer<OrderPlaced>
{
private readonly IPaymentGateway _gateway;
private readonly IIdempotencyStore _seen; // Redis-backed
private readonly ILogger<OrderPlacedConsumer> _log;
public OrderPlacedConsumer(IPaymentGateway gateway, IIdempotencyStore seen,
ILogger<OrderPlacedConsumer> log)
=> (_gateway, _seen, _log) = (gateway, seen, log);
public async Task Consume(ConsumeContext<OrderPlaced> ctx)
{
var msg = ctx.Message;
// IDEMPOTENCY: brokers deliver "at least once" — the same event can
// arrive twice. Charging twice is unacceptable. Dedupe on message id.
if (await _seen.AlreadyProcessed(ctx.MessageId!.Value))
{
_log.LogInformation("Duplicate OrderPlaced {OrderId}, skipping", msg.OrderId);
return;
}
try
{
var result = await _gateway.ChargeAsync(msg.Total, msg.CustomerId);
await _seen.MarkProcessed(ctx.MessageId.Value);
if (result.Success)
await ctx.Publish(new PaymentCaptured(msg.OrderId, result.TransactionId, DateTime.UtcNow));
else
await ctx.Publish(new OrderFailed(msg.OrderId, "Payment declined", DateTime.UtcNow));
}
catch (TransientException)
{
throw; // let MassTransit retry; after N retries it dead-letters automatically
}
}
}
// Retry + dead-letter policy (per consumer endpoint)
cfg.ReceiveEndpoint("payment-order-placed", e =>
{
e.UseMessageRetry(r => r.Exponential(5,
TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(2)));
e.ConfigureConsumer<OrderPlacedConsumer>(ctx);
// Messages that fail all retries land in payment-order-placed_error (DLQ)
});
Two non-negotiables: idempotency (dedupe duplicate deliveries) and retry + dead-letter (transient failures retry; poison messages get quarantined).
5.4 OrderService completes the saga
// Consumers/PaymentCapturedConsumer.cs — in OrderService
public class PaymentCapturedConsumer : IConsumer<PaymentCaptured>
{
private readonly OrderDbContext _db;
public PaymentCapturedConsumer(OrderDbContext db) => _db = db;
public async Task Consume(ConsumeContext<PaymentCaptured> ctx)
{
var order = await _db.Orders.FindAsync(ctx.Message.OrderId);
if (order is null) return;
order.MarkPaymentCaptured(ctx.Message.TransactionId);
if (order.IsFullyReserved) // confirm only when payment AND stock are done
{
order.Confirm();
await ctx.Publish(new OrderConfirmed(order.Id, DateTime.UtcNow));
}
await _db.SaveChangesAsync();
}
}
This "wait for multiple events before proceeding" coordination is the Saga pattern — covered in depth in our SAGA / Outbox guides.
5.5 NotifyService → push live updates to React via SignalR
// NotifyService — bridges broker events to the browser over SignalR
public class OrderConfirmedConsumer : IConsumer<OrderConfirmed>
{
private readonly IHubContext<OrderHub> _hub;
public OrderConfirmedConsumer(IHubContext<OrderHub> hub) => _hub = hub;
public async Task Consume(ConsumeContext<OrderConfirmed> ctx)
{
await _hub.Clients
.Group($"order-{ctx.Message.OrderId}")
.SendAsync("OrderStatusChanged", new { orderId = ctx.Message.OrderId, status = "Confirmed" });
}
}
public class OrderHub : Hub
{
public async Task WatchOrder(Guid orderId) =>
await Groups.AddToGroupAsync(Context.ConnectionId, $"order-{orderId}");
}
5.6 The React frontend — live order status
// useOrderStatus.ts
import { useEffect, useState } from 'react';
import * as signalR from '@microsoft/signalr';
export function useOrderStatus(orderId: string, initialStatus: string) {
const [status, setStatus] = useState(initialStatus);
useEffect(() => {
const connection = new signalR.HubConnectionBuilder()
.withUrl('/hubs/orders')
.withAutomaticReconnect()
.build();
connection.on('OrderStatusChanged', (update: { orderId: string; status: string }) => {
if (update.orderId === orderId) setStatus(update.status);
});
connection.start().then(() => connection.invoke('WatchOrder', orderId));
return () => void connection.stop();
}, [orderId]);
return status;
}
// OrderTracker.tsx
import { useOrderStatus } from './useOrderStatus';
export function OrderTracker({ orderId }: { orderId: string }) {
const status = useOrderStatus(orderId, 'Pending');
const steps = ['Pending', 'PaymentCaptured', 'Confirmed', 'Shipped'];
const current = steps.indexOf(status);
return (
<div className="order-tracker">
<h3>Order #{orderId.slice(0, 8)}</h3>
<ol className="steps">
{steps.map((s, i) => (
<li key={s} className={i <= current ? 'done' : 'pending'}>{s}</li>
))}
</ol>
{status === 'Pending' && <p>Processing your order…</p>}
{status === 'Confirmed' && <p>Confirmed!</p>}
</div>
);
}
The user submits an order, sees "Pending" instantly (the fast 202), then watches it tick to "Confirmed" in real time as the async events flow through — without polling. That''s the EDA payoff on the frontend.
6. The challenges — what EDA makes harder (be honest)
EDA trades synchronous problems for these:
6.1 Eventual consistency
After OrderPlaced, the order isn''t confirmed yet. Your UI and domain must tolerate "in flight" states. If your domain needs strong consistency, EDA fights you.
6.2 Duplicate delivery (at-least-once)
Brokers guarantee at-least-once, not exactly-once. The same event will be delivered twice sometimes. Every consumer with side effects must be idempotent.
6.3 Out-of-order delivery
PaymentCaptured can arrive before something else finished. Don''t assume order. Use partitioning (by OrderId) when ordering within an entity matters.
6.4 Debugging across the boundary
A failure is "the order never confirmed" with the cause three hops away. Distributed tracing (OpenTelemetry) is mandatory. Without one trace_id flowing through every service and the broker, you''re blind.
6.5 Schema evolution
Change an event''s shape and you can break every consumer. Only add optional fields, never remove/repurpose, and version (OrderPlacedV2) for breaking changes.
6.6 Operational complexity
You now run a broker, monitor queue depth, manage DLQs, and reason about distributed state. Real ongoing cost. For a small team, it can outweigh the benefits.
7. Production performance — the levers and the reasoning
7.1 The Outbox (reliability → enables everything else)
Lever: write the event in the same DB transaction as the data; a dispatcher publishes afterward. Why: without it, a crash between "save order" and "publish event" loses the event silently. Performance work is pointless if events are being lost.
7.2 Idempotent consumers (correctness under retries)
Lever: dedupe on message ID, or make operations naturally idempotent (UPSERT).
Why: at-least-once + retries guarantee duplicates. Idempotency lets you retry aggressively without double-charging.
7.3 Prefetch + concurrency (per-instance throughput)
cfg.ReceiveEndpoint("payment-order-placed", e =>
{
e.PrefetchCount = 32; // pull 32 ahead — hides network latency
e.ConcurrentMessageLimit = 16; // process 16 at once per instance
e.ConfigureConsumer<OrderPlacedConsumer>(ctx);
});
Why: prefetch keeps the pipeline full (round-trip latency dominates otherwise); concurrency parallelizes. Tune ConcurrentMessageLimit to the work — CPU-bound wants ~CPU count; IO-bound can go much higher.
7.4 Partitioning (ordered throughput)
Lever: partition the topic by entity key (OrderId).
Why: ordering within an entity AND parallelism across entities. Without it you choose one or the other.
7.5 Consumer autoscaling (elastic throughput)
# KEDA ScaledObject — scale payment consumers on Service Bus queue length
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata: { name: payment-consumer }
spec:
scaleTargetRef: { name: payment-service }
minReplicaCount: 2
maxReplicaCount: 20
triggers:
- type: azure-servicebus
metadata: { queueName: payment-order-placed, messageCount: "50" }
Why: the broker absorbs spikes; you add consumers when the backlog grows and remove them when it drains. You pay for capacity only when you need it.
7.6 Dead-letter queues + monitoring (resilience)
Lever: failed-after-retries messages go to a DLQ; alert on DLQ depth and queue lag. Why: a poison message would otherwise block the queue or retry forever. The DLQ quarantines it. Always alert on: queue depth growing, DLQ non-empty, consumer lag.
7.7 Batching (high-volume efficiency)
Lever: for high-throughput, low-criticality events (analytics), batch publishes/consumes. Why: one round-trip for 100 events instead of 100. Throughput soars; per-event latency rises slightly. Use for telemetry, not payments.
7.8 Backpressure (stability under overload)
Lever: cap prefetch and concurrency so a consumer never pulls more than it can handle. Why: without it, a spike floods the consumer, exhausts resources, and it crashes — making the backlog worse. Bounded prefetch turns a crash into a graceful slowdown.
7.9 The performance summary
| Lever | Buys you | Cost / trade-off |
|---|---|---|
| Outbox | No lost events | One extra table + dispatcher |
| Idempotency | Safe aggressive retries | A dedupe store (Redis) |
| Prefetch + concurrency | Per-instance throughput | Tune carefully or OOM |
| Partitioning | Ordering + parallelism | Key design; can''t reorder later |
| Autoscaling (KEDA) | Elastic capacity | K8s + autoscaler setup |
| DLQ + alerts | Poison isolation, visibility | Ops process to drain DLQ |
| Batching | High-volume efficiency | Higher per-event latency |
| Backpressure | Stability under spike | Lower peak throughput |
8. Step-by-step: integrating EDA into an existing system
Don''t rewrite everything at once. Incremental adoption:
- Start with one event, one consumer. Pick a synchronous cross-cutting call (e.g., "send confirmation email"); make the producer emit
OrderPlacedand move email into a consumer. - Add the Outbox immediately. Get reliable publishing in place before you have two consumers.
- Make every consumer idempotent from day one. Add the dedupe store now.
- Wire distributed tracing before the second service. OpenTelemetry across producer → broker → consumer.
- Add a second consumer to the same event. Now you feel the payoff — a new feature with zero producer changes.
- Introduce dead-letter handling + queue-depth alerts before production traffic.
- Add the React/SignalR live-update layer once the backend flow is solid.
- Load-test and tune prefetch / concurrency / partitions under spike load.
- Add autoscaling (KEDA) once you know per-instance throughput.
- Document + version the event contracts. Treat an event schema like a public API.
Each step is independently shippable. You''re never in a "big bang rewrite" state.
9. Why EDA beats the alternatives (for the right problem)
| Approach | Coupling | Latency | Scalability | When EDA wins |
|---|---|---|---|---|
| Synchronous REST chain | Tight | Sum of all hops; fails if any down | Bottlenecked by slowest service | Fan-out, async work, spiky load |
| Shared database | Very tight (schema coupling) | Fast reads | Poor (DB bottleneck) | Always — shared DB couples teams catastrophically |
| Batch / cron jobs | Loose | Minutes–hours | OK | When you need near-real-time reactions |
| Event-driven | Loose | Fast producer response; eventual completion | Excellent | Decoupled services, real-time fan-out, elastic load |
The honest framing: EDA wins when decoupling and asynchronous scalability matter more than simplicity and strong consistency.
10. When to use EDA — and when NOT to
10.1 Use EDA when
- You have multiple services reacting to the same business events (fan-out).
- Work can happen asynchronously — the user doesn''t need to wait for everything.
- You have spiky or unpredictable load that benefits from queue-based load leveling.
- Services are owned by different teams that deploy independently.
- You need a real-time UI reflecting backend state changes (live dashboards, order tracking).
- You want an audit trail / event history for free.
10.2 Do NOT use EDA when
- It''s a simple CRUD app. A monolith with synchronous calls is faster to build and easier to debug. EDA here is pure overhead.
- You need strong, immediate consistency. Banking transfers where the balance must be correct the instant the call returns are a poor fit.
- The workflow is inherently synchronous. If the user genuinely must wait for the result, a synchronous call is clearer.
- Your team is small and lacks ops maturity. Running a broker, monitoring queues, managing DLQs, debugging distributed flows is real ongoing work.
- Low traffic, no scale problem. EDA''s scalability benefits don''t apply at 10 requests/minute — you''d pay all the complexity cost for none of the benefit.
10.3 The decision rule
Adopt EDA when the cost of coupling and blocking exceeds the cost of eventual consistency and operational complexity. If you can''t clearly articulate which side is heavier for your system, you probably don''t need it yet.
Start with a well-structured monolith or synchronous services. Introduce EDA at the specific seams where decoupling and async genuinely pay off — not as a default architecture.
11. The honest stuff
- EDA solves coupling and scale by adding distributed-systems problems. Make sure you''re trading up, not sideways.
- The Outbox and idempotency are not optional. Skip them and you''ll lose events or double-process.
- You cannot operate EDA blind. Distributed tracing + queue monitoring are part of the build.
- Eventual consistency leaks into the UX. Design the frontend for "in progress" states from the start.
- Event contracts are public APIs. Version them with care.
- Start small, expand at the seams. Teams that succeed adopt EDA incrementally; those that fail try to event-source everything on day one.
12. The mental checklist
Before shipping an event-driven feature:
- Events are immutable, past-tense facts with a timestamp + ID.
- Producer uses the Outbox (event saved in the same transaction as the data).
- Every consumer is idempotent (dedupe on message ID).
- Retry policy + dead-letter queue configured per consumer.
- Distributed tracing (
trace_id) flows producer → broker → consumer. - Queue-depth, DLQ, and consumer-lag alerts are wired.
- Ordering needs are met (partition by entity key where required).
- The UI handles eventual/"in progress" states.
- Event contracts are versioned and documented.
- You''ve confirmed EDA is actually warranted (re-read Section 10).
13. Closing — the right mental model
Event-driven architecture is a tool for decoupling services in time and dependency — producers emit facts, consumers react, and neither knows about the other. That buys you independent scaling, independent deployment, real-time fan-out, and a free audit log. It costs you eventual consistency, duplicate delivery, and operational complexity.
Three habits that make EDA succeed:
- Reliability before performance. Outbox + idempotency first; tuning is meaningless if events are lost or double-processed.
- Observability is part of the architecture. Tracing and queue monitoring aren''t add-ons.
- Adopt at the seams, not as a religion. Use EDA where decoupling and async genuinely pay off; keep the simple parts simple.
Get those right, build the order flow above, and EDA stops being a buzzword that bites you — it becomes a deliberate, well-understood trade you make where it actually wins.
Further reading
- Martin Fowler — Event-Driven Architecture — the canonical taxonomy.
- MassTransit docs — the .NET messaging framework used here.
- Microsoft — Event-driven architecture style — Azure reference.
- Enterprise Integration Patterns — Hohpe & Woolf. The messaging-patterns bible.
- KEDA — event-driven autoscaling for Kubernetes.
- Designing Data-Intensive Applications — Kleppmann. The deep theory behind logs, ordering, and consistency.
Building event-driven and hitting duplicate processing, lost events, or eventual-consistency UX pain? Email randhir.jassal@gmail.com with your flow and I''ll tell you which pattern (Outbox, idempotency, partitioning) you''re missing.
Get the next issue
A short, curated email with the newest posts and questions.