DEV Community

Vikrant Bagal
Vikrant Bagal

Posted on

Event-Driven Architecture with .NET 2026: Building Scalable Real-Time Systems

Event-Driven Architecture with .NET 2026: Building Scalable Real-Time Systems

Event-Driven Architecture (EDA) has evolved significantly by 2026. With .NET 9/10's native improvements, abstracted messaging frameworks, and production-proven patterns, building scalable real-time systems is more achievable than ever. This guide covers everything you need to know about implementing EDA in modern .NET applications.

The Modern EDA Landscape (2026)

By 2026, Event-Driven Architecture has matured from "fire-and-forget" patterns to structured, observable, and resilient distributed systems. The focus has shifted toward:

  • Native high-performance primitives (Server-Sent Events, Channels)
  • Abstracted messaging frameworks (MassTransit, Azure Service Bus)
  • Strong adherence to Clean Architecture combined with CQRS
  • Distributed tracing and observability (OpenTelemetry)
  • AOT optimization for serverless environments

.NET 9/10 Game-Changers for EDA

1. Native Server-Sent Events (SSE)

.NET 10 introduces System.Net.ServerSentEvents, providing native, high-performance SSE without relying on SignalR:

using System.Net;
using System.Threading.Channels;

public class EventsController : ControllerBase
{
    private readonly Channel<ServerSentEvent> _events = Channel.CreateUnbounded<ServerSentEvent>();

    [HttpGet("/events")]
    public async Task<ActionResult> StreamEvents()
    {
        return Results.Streaming(
            async (context, cancellationToken) =>
            {
                while (!cancellationToken.IsCancellationRequested)
                {
                    var @event = await _events.Reader.ReadAsync(cancellationToken);
                    await context.Response.WriteAsync(@event.ToString(), cancellationToken);
                    await context.Response.Body.FlushAsync(cancellationToken);
                }
            },
            MediaTypeNames.Text.EventStream
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

2. Structured Background Processing

Move away from Task.Run (fire-and-forget) toward System.Threading.Channels for safe, backpressure-aware processing:

public class OrderProcessor : BackgroundService
{
    private readonly Channel<OrderEvent> _orderQueue = Channel.CreateBounded<OrderEvent>(
        new BoundedChannelOptions(1000) { SingleReader = true, SingleWriter = false }
    );

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                var @event = await _orderQueue.Reader.ReadAsync(stoppingToken);
                await ProcessOrderAsync(@event, stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // Graceful shutdown
                while (_orderQueue.Reader.TryRead(out var @event))
                {
                    await ProcessOrderAsync(@event, stoppingToken);
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

3. NativeAOT for Event Consumers

Maximize NativeAOT performance for event consumers in serverless environments:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
    <PublishAot>true</PublishAot>
    <NativeAOT>true</NativeAOT>
  </PropertyGroup>
</Project>
Enter fullscreen mode Exit fullscreen mode

Messaging Platform Comparison (2026)

Azure Service Bus → Enterprise Messaging

Best for: Strong guarantees, dead-lettering, scheduled delivery, compliance requirements.

public interface IEventBus
{
    Task PublishAsync(IntegrationEvent @event, CancellationToken ct = default);
    Task<bool> TryPublishAsync(IntegrationEvent @event, CancellationToken ct = default);
}

public class AzureServiceBusEventBus : IEventBus
{
    private readonly string _topicName;
    private readonly ClientSecretCredential _credential;

    public AzureServiceBusEventBus(string topicName, string connectionString)
    {
        _topicName = topicName;
        _credential = new ClientSecretCredential(
            "your-tenant-id",
            "your-app-id",
            "your-app-secret"
        );
    }

    public async Task PublishAsync(IntegrationEvent @event, CancellationToken ct = default)
    {
        var topicClient = new TopicPublisher(
            new Uri($"https://{_topicName}.servicebus.windows.net"),
            _credential,
            new TopicPublisherOptions { RetryOptions = { MaxRetries = 0 } }
        );

        await topicClient.SendMessageAsync(
            new MessageBody(System.Text.Json.JsonSerializer.Serialize(@event)),
            ct
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

RabbitMQ → High-Throughput Internal Routing

Best for: Low-latency, high-throughput internal microservices communication.

public class RabbitMqEventBus : IEventBus
{
    private readonly IConnection _connection;
    private readonly IModel _channel;

    public RabbitMqEventBus(IConnection connection)
    {
        _connection = connection;
        _channel = connection.CreateModel();
        _channel.BasicQos(prefetchSize: 0, prefetchCount: 100, global: false);
    }

    public async Task PublishAsync(IntegrationEvent @event, CancellationToken ct = default)
    {
        var body = System.Text.Encoding.UTF8.GetBytes(
            System.Text.Json.JsonSerializer.Serialize(@event)
        );

        _channel.BasicPublish(
            exchange: "events",
            routingKey: @event.EventName,
            body: body
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Kafka → Event Streaming & Log-Based Architecture

Best for: High-volume event streaming, audit logs, event sourcing.

public class KafkaEventBus : IEventBus
{
    private readonly IProducer<string, byte[]> _producer;

    public KafkaEventBus(string bootstrapServers)
    {
        var config = new ProducerConfig
        {
            BootstrapServers = bootstrapServers,
            Acks = AckOffsets.All,
            EnableIdempotence = true
        };

        _producer = new ProducerBuilder<string, byte[]>(config)
            .SetValueSerializer(new ByteArraySerializer())
            .Build();
    }

    public Task PublishAsync(IntegrationEvent @event, CancellationToken ct = default)
    {
        var message = new Message<string, byte[]>
        {
            Key = @event.AggregateId.ToString(),
            Value = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(@event)
        };

        return _producer.ProduceAsync(@event.EventName, message, ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Outbox Pattern: Essential for Data Consistency

The Outbox Pattern ensures atomicity between database updates and event publishing:

public class OrderAggregate : AggregateRoot
{
    private readonly List<DomainEvent> _uncommittedEvents = new();

    public void PlaceOrder(OrderItem item)
    {
        // Business logic
        var order = new Order(Id, item);

        // Add domain event
        _uncommittedEvents.Add(new OrderPlacedEvent(order.Id));
    }

    protected override async Task<(Order, List<IntegrationEvent>)> SaveChangesAsync(
        ApplicationDbContext context,
        CancellationToken ct
    )
    {
        await context.Orders.AddAsync(this, ct);
        await context.SaveChangesAsync(ct);

        // Convert domain events to integration events
        var integrationEvents = _uncommittedEvents
            .Select(e => e.ToIntegrationEvent())
            .ToList();

        // Save to outbox
        await context.OutboxMessages.AddRangeAsync(
            integrationEvents.Select(e => new OutboxMessage
            {
                MessageId = e.Id,
                MessageData = Serialize(e),
                MessageTime = DateTime.UtcNow,
                IsProcessed = false
            }),
            ct
        );

        await context.SaveChangesAsync(ct);

        return (this, integrationEvents);
    }
}
Enter fullscreen mode Exit fullscreen mode

Saga Pattern: Orchestrating Distributed Transactions

Choreography Pattern

// Service A: Order Service
On[OrderPlaced]: {
  // Publish OrderPlaced event (async)
  return new Event("OrderPlaced", orderData);
}

// Service B: Inventory Service  
On[OrderPlaced]: {
  // Check inventory (sync)
  if (inventory > 0) {
    // Publish InventoryReserved
    return new Event("InventoryReserved", orderId);
  } else {
    // Publish OrderCancelled
    return new Event("OrderCancelled", orderId, "Out of stock");
  }
}

// Service C: Payment Service
On[InventoryReserved]: {
  // Process payment (sync)
  if (payment.success) {
    return new Event("PaymentCompleted", orderId);
  } else {
    return new Event("PaymentFailed", orderId);
  }
}
Enter fullscreen mode Exit fullscreen mode

Orchestration Pattern (Recommended for Complex Flows)

public class OrderSaga : IStatelessSaga<OrderSagaContext>
{
    public SagaStatus Handle(OrderStartedEvent @event, IContext context)
    {
        context.OrderId = @event.OrderId;
        context.State = SagaState.ReservingInventory;

        return this.Publish(new ReservingInventoryCommand(@event.OrderId));
    }

    public SagaStatus Handle(InventoryReservedEvent @event, IContext context)
    {
        context.State = SagaState.ProcessingPayment;

        return this.Publish(new ProcessPaymentCommand(@event.OrderId, @event.Amount));
    }

    public SagaStatus Handle(PaymentCompletedEvent @event, IContext context)
    {
        context.State = SagaState.Completed;

        return this.Publish(new OrderConfirmedEvent(@event.OrderId));
    }

    public SagaStatus Handle(PaymentFailedEvent @event, IContext context)
    {
        context.State = SagaState.Cancelled;

        return this.Publish(new OrderCancelledEvent(@event.OrderId, @event.Reason));
    }
}
Enter fullscreen mode Exit fullscreen mode

Clean Architecture Integration

Layered Structure

┌─────────────────────────────────────────────────┐
│              PRESENTATION LAYER                  │
│  (Controllers, Minimal APIs, Event Streams)      │
└─────────────────────────────────────────────────┘
                          ↕
┌─────────────────────────────────────────────────┐
│              APPLICATION LAYER                   │
│  (Commands, Queries, Validators)                 │
│  (Outbox Publisher, Saga Orchestrator)           │
└─────────────────────────────────────────────────┘
                          ↕
┌─────────────────────────────────────────────────┐
│                DOMAIN LAYER                      │
│  (Aggregate Roots, Domain Events, Value Objects) │
└─────────────────────────────────────────────────┘
                          ↕
┌─────────────────────────────────────────────────┐
│           INFRASTRUCTURE LAYER                   │
│  (Event Bus, Message Queues, Outbox Storage)     │
└─────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Clean Event Bus Interface

// Domain Layer: Event Interface
public interface IEventBus
{
    Task PublishAsync(T @event, CancellationToken ct = default);
}

// Infrastructure Layer: Implementation
public class AzureServiceBusEventBus : IEventBus
{
    public async Task PublishAsync(IntegrationEvent @event, CancellationToken ct = default)
    {
        // Azure Service Bus implementation
    }
}

// Application Layer: Use
public class OrderService
{
    private readonly IEventBus _eventBus;

    public async Task<Order> PlaceOrder(PlaceOrderCommand command, CancellationToken ct)
    {
        var order = new Order();
        order.PlaceItems(command.Items);

        // Publish domain events
        foreach (var domainEvent in order.DomainEvents)
        {
            await _eventBus.PublishAsync(domainEvent.ToIntegrationEvent(), ct);
        }

        return order;
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance & Scaling Best Practices

1. BoundedChannel for Backpressure

var channel = Channel.CreateBounded<OrderEvent>(
    new BoundedChannelOptions(1000)
    {
        SingleWriter = true,
        SingleReader = true,
        FullMode = BoundedChannelFullMode.DropOldest
    }
);
Enter fullscreen mode Exit fullscreen mode

2. Competing Consumers Pattern

// Multiple worker instances consuming from same queue
var options = new WorkerServiceOptions
{
    Concurrency = 10, // Parallel message processing
    MaxConcurrentCalls = 100,
    AutoCommit = true
};

// Each instance handles different messages independently
// Scale horizontally by adding more instances
Enter fullscreen mode Exit fullscreen mode

3. Message Batching

public async Task ProcessMessagesAsync(
    IChannelReader<QueueMessage> reader,
    CancellationToken ct
)
{
    var batch = new List<QueueMessage>();
    const int MaxBatchSize = 10;
    const int MaxWaitMs = 100;

    while (!ct.IsCancellationRequested)
    {
        batch.Clear();

        // Collect messages up to max batch size or timeout
        var stopWatch = Stopwatch.StartNew();
        while (batch.Count < MaxBatchSize && 
               stopWatch.ElapsedMilliseconds < MaxWaitMs)
        {
            if (await reader.WaitToReadAsync(ct))
            {
                batch.Add(await reader.ReadAsync(ct));
            }
        }

        // Process batch in single operation
        if (batch.Count > 0)
        {
            await ProcessBatchAsync(batch, ct);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Anti-Patterns to Avoid

❌ The "Entity-Event" Trap

// DON'T: Publish entire entity with all properties
public class OrderEvent
{
    public Guid OrderId { get; set; }
    public string CustomerName { get; set; }
    public string CustomerAddress { get; set; }
    public decimal Total { get; set; }
    public List<OrderItem> Items { get; set; }
    // ... all 50+ properties included
}

// DO: Use lean integration events
public class OrderPlacedEvent
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public decimal TotalAmount { get; set; }
    public int ItemCount { get; set; }
    // Only essential data for downstream consumers
}
Enter fullscreen mode Exit fullscreen mode

❌ Distributed Monolith

// DON'T: Synchronous calls between services via events
public async Task<Order> CreateOrder(CreateOrderCommand cmd)
{
    await PublishOrderCreatedAsync(cmd);

    // Wait for synchronous response!
    var customerData = await WaitForCustomerSyncAsync(cmd.CustomerId);

    await PublishCustomerOrderedAsync(customerData);

    return order;
}

// DO: Embrace eventual consistency
public async Task<Order> CreateOrder(CreateOrderCommand cmd)
{
    await PublishOrderCreatedAsync(cmd);
    // Fire-and-forget - don't wait for response
    return order;
}
Enter fullscreen mode Exit fullscreen mode

OpenTelemetry Integration

public class OpenTelemetryEventBus : IEventBus
{
    private readonly Tracer _tracer;

    public async Task PublishAsync(IntegrationEvent @event, CancellationToken ct = default)
    {
        using var span = _tracer.StartSpan("publish.event");
        span.SetTag("event.type", @event.EventName);
        span.SetTag("event.id", @event.Id.ToString());

        // Propagate trace context
        var properties = new Dictionary<string, string>
        {
            { "traceparent", span.Context.TraceId.ToString("X") },
            { "tracestate", span.Context.SpanId.ToString("X") }
        };

        await _publisher.PublishAsync(@event, properties, ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Checklist (2026)

  1. Use Outbox Pattern: Ensure data-event consistency
  2. Implement Dead Letter Queues: Handle poison messages
  3. Enable Distributed Tracing: Track events across services
  4. Set Up Monitoring: Track message latency, queue depths
  5. Implement Retry Policies: With exponential backoff
  6. Use BoundedChannels: For backpressure management
  7. Batch Messages: Optimize throughput
  8. Deploy Competing Consumers: Horizontal scaling
  9. Validate Message Contracts: Versioning strategy
  10. Test Failure Scenarios: Chaos engineering for EDA

Conclusion

Event-Driven Architecture with .NET 2026 offers unparalleled scalability and responsiveness when built with the right patterns. By combining native .NET 9/10 improvements with proven architecture patterns, you can build systems that scale horizontally, handle failures gracefully, and provide real-time responsiveness.

Remember: Start simple, iterate based on real-world requirements, and always prioritize eventual consistency over immediate consistency in distributed systems.

What EDA challenges have you faced in production? Share your experiences and lessons learned in the comments!

DotNet #EventDrivenArchitecture #Microservices #DistributedSystems #AzureServiceBus #RabbitMQ #Kafka #CloudNative #2026Tech

Top comments (0)