Skip to content

Event-Driven Architecture

Event-Driven Architecture (EDA) is a design pattern where components communicate by producing and consuming events rather than calling each other directly.

ConceptDescription
EventA record of something that happened (past tense)
ProducerThe component that emits the event
ConsumerThe component that reacts to the event
Event BrokerThe infrastructure that routes events (Kafka, RabbitMQ, Azure Service Bus)
Producer โ”€โ”€โ–บ [ Event Broker ] โ”€โ”€โ–บ Consumer A
โ”€โ”€โ–บ Consumer B
โ”€โ”€โ–บ Consumer C

Domain Event โ€” something meaningful happened in the domain:

public record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total, DateTime OccurredAt);
public record PaymentReceived(Guid PaymentId, Guid OrderId, decimal Amount);
public record ItemShipped(Guid OrderId, string TrackingNumber);

Integration Event โ€” crosses service boundaries (published to a message broker):

public record UserRegisteredIntegrationEvent(Guid UserId, string Email, DateTime RegisteredAt);

Useful within a single application, no broker needed:

// Define a simple dispatcher
public interface IDomainEventDispatcher
{
Task DispatchAsync<T>(T domainEvent) where T : IDomainEvent;
}
// Raise events inside domain entities
public class Order
{
private readonly List<IDomainEvent> _events = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _events;
public void Place()
{
Status = OrderStatus.Placed;
_events.Add(new OrderPlaced(Id, CustomerId, Total, DateTime.UtcNow));
}
}
// Dispatch after saving to DB
public class PlaceOrderHandler
{
public async Task HandleAsync(PlaceOrderCommand cmd)
{
var order = new Order(cmd.CustomerId, cmd.Lines);
order.Place();
await _repo.SaveAsync(order);
foreach (var e in order.DomainEvents)
await _dispatcher.DispatchAsync(e);
}
}
Terminal window
dotnet add package MassTransit.RabbitMQ

Producer:

public class OrderService
{
private readonly IPublishEndpoint _bus;
public OrderService(IPublishEndpoint bus) => _bus = bus;
public async Task PlaceOrderAsync(PlaceOrderDto dto)
{
// ... save order to DB ...
await _bus.Publish(new OrderPlacedEvent(order.Id, order.CustomerId, order.Total));
}
}

Consumer:

public class SendConfirmationEmailConsumer : IConsumer<OrderPlacedEvent>
{
private readonly IEmailService _email;
public SendConfirmationEmailConsumer(IEmailService email) => _email = email;
public async Task Consume(ConsumeContext<OrderPlacedEvent> context)
{
var e = context.Message;
await _email.SendOrderConfirmationAsync(e.CustomerId, e.OrderId);
}
}

Registration:

builder.Services.AddMassTransit(x =>
{
x.AddConsumer<SendConfirmationEmailConsumer>();
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host("rabbitmq://localhost");
cfg.ConfigureEndpoints(ctx);
});
});
// Send a message
await serviceBusClient.CreateSender("orders").SendMessageAsync(
new ServiceBusMessage(JsonSerializer.Serialize(new OrderPlacedEvent(order.Id))));
// Receive and process
var processor = serviceBusClient.CreateProcessor("orders");
processor.ProcessMessageAsync += async args =>
{
var e = JsonSerializer.Deserialize<OrderPlacedEvent>(args.Message.Body);
await HandleOrderPlaced(e);
await args.CompleteMessageAsync(args.Message);
};

Prevents events from being lost if the broker is down when the DB transaction commits:

// Save event to outbox table in the same DB transaction
using var tx = await _db.BeginTransactionAsync();
await _db.Orders.AddAsync(order);
await _db.OutboxMessages.AddAsync(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(OrderPlacedEvent),
Payload = JsonSerializer.Serialize(new OrderPlacedEvent(order.Id)),
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
await tx.CommitAsync();
// Background worker reads outbox and publishes to broker
AspectRequest-ResponseEvent-Driven
CouplingTight (caller knows callee)Loose (producer doesnโ€™t know consumers)
AvailabilityBoth sides must be upConsumer can be down, processes later
ConsistencySynchronous, immediateEventually consistent
ComplexitySimpleHigher (broker, error handling, ordering)
ScalabilityLimited by callee capacityConsumers scale independently

Good fit for:

  • Decoupling microservices
  • Workflows that trigger multiple downstream actions
  • Audit trails and event sourcing
  • Long-running processes (order fulfillment, billing)

Avoid when:

  • You need an immediate synchronous response
  • Simple CRUD with no downstream side effects
  • Team is small and the operational overhead isnโ€™t justified