
Series Navigation
7 PartsComplete Event-Driven Architecture & Apache Kafka Blog Series
Series Overview
Introduction to Event-Driven Architecture - Building Your First Kafka Example
A Beginner's Guide
Designing Events - The Heart of Event-Driven Architecture
Poor event design leads to tight coupling, versioning nightmares, and systems that are hard to evolve.
Introduction to Apache Kafka - The Event Streaming Platform
This is where traditional message queues fall short, and where Apache Kafka excels.
Hands-On Kafka - Building Your First Event-Driven Application
In this part, we'll build a complete event-driven e-commerce system using Kafka.
Advanced Kafka Concepts - Mastering Production Kafka
Let's dive deep into advanced concepts that separate hobby projects from production systems.
Advanced Event-Driven Patterns - CQRS, Event Sourcing, and Sagas
Let's explore advanced architectural patterns that leverage events to build sophisticated, scalable applications.
Operating Event-Driven Systems in Production
Now comes the hard part: running it reliably in production
Part 7: Operating Event-Driven Systems in Production - Complete Guide with Working Demo
Introduction
You've learned the theory of event-driven architecture, built your first Kafka application, and explored advanced patterns. Now comes the hardest part: running it reliably in production.
In this comprehensive guide, we'll cover everything you need to operate event-driven systems at scale, and then build a complete working demonstration you can run on your laptop.
What we'll cover:
Part A: Production Operations Theory
- Monitoring and observability
- Debugging distributed systems
- Testing strategies
- Deployment patterns
- Incident response
- Best practices
Part B: Building a Complete Working Demo
- Full C# microservices with Kafka
- Prometheus + Grafana + Jaeger observability stack
- Interactive React UI
- Load testing tools
- Step-by-step implementation
Let's dive in!
Part A: Production Operations Theory
The Three Pillars of Observability
graph LR
subgraph "Observability"
M[Metrics<br/>What is happening?]
L[Logs<br/>Why did it happen?]
T[Traces<br/>Where did it happen?]
end
M --> Insight[System Insight]
L --> Insight
T --> Insight
style Insight fill:#00b894
1. Metrics - Key Performance Indicators
Producer Metrics to Track:
using Prometheus;
// Events produced
var eventsProduced = Metrics.CreateCounter(
"events_produced_total",
"Total events produced",
new CounterConfiguration { LabelNames = new[] { "topic", "event_type" } });
// Production failures
var eventsFailed = Metrics.CreateCounter(
"events_failed_total",
"Total failed event productions",
new CounterConfiguration { LabelNames = new[] { "topic", "error_type" } });
// Production latency
var productionDuration = Metrics.CreateHistogram(
"event_production_duration_seconds",
"Time to produce event",
new HistogramConfiguration { LabelNames = new[] { "topic" } });
// Usage
eventsProduced.WithLabels("orders", "OrderPlaced").Inc();
using (productionDuration.WithLabels("orders").NewTimer())
{
await producer.ProduceAsync("orders", message);
}
Consumer Metrics to Track:
// Consumer lag - CRITICAL metric
var consumerLag = Metrics.CreateGauge(
"consumer_lag",
"Consumer lag in messages",
new GaugeConfiguration {
LabelNames = new[] { "consumer_group", "topic", "partition" }
});
// Events consumed
var eventsConsumed = Metrics.CreateCounter(
"events_consumed_total",
"Total events consumed",
new CounterConfiguration {
LabelNames = new[] { "consumer_group", "topic", "event_type" }
});
// Processing duration
var processingDuration = Metrics.CreateHistogram(
"event_processing_duration_seconds",
"Time to process event",
new HistogramConfiguration {
LabelNames = new[] { "consumer_group", "event_type" }
});
// Processing errors
var processingErrors = Metrics.CreateCounter(
"processing_errors_total",
"Total processing errors",
new CounterConfiguration {
LabelNames = new[] { "consumer_group", "error_type" }
});
Business Metrics:
// Track business outcomes, not just technical metrics
var ordersPlaced = Metrics.CreateCounter(
"orders_placed_total",
"Total orders placed",
new CounterConfiguration { LabelNames = new[] { "status" } });
var orderValue = Metrics.CreateHistogram(
"order_value_dollars",
"Order value in dollars",
new HistogramConfiguration {
Buckets = new[] { 10, 50, 100, 500, 1000, 5000 }
});
var sagaDuration = Metrics.CreateHistogram(
"saga_duration_seconds",
"Saga execution time",
new HistogramConfiguration {
LabelNames = new[] { "saga_type", "status" }
});
2. Distributed Tracing - Following Requests Across Services
OpenTelemetry Setup in C#:
using OpenTelemetry;
using OpenTelemetry.Trace;
using OpenTelemetry.Resources;
using System.Diagnostics;
// In Program.cs
builder.Services.AddOpenTelemetry()
.WithTracing(tracerProvider =>
{
tracerProvider
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("order-service"))
.AddSource("order-service")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddJaegerExporter(options =>
{
options.AgentHost = "localhost";
options.AgentPort = 6831;
});
});
// Create ActivitySource
var activitySource = new ActivitySource("order-service");
// Producer with tracing
public async Task ProduceOrderEvent(Order order)
{
using var activity = activitySource.StartActivity(
"produce_order_event",
ActivityKind.Producer);
activity?.SetTag("order.id", order.OrderId);
activity?.SetTag("order.amount", order.TotalAmount);
// Add correlation ID to event
var correlationId = activity?.Id ?? Guid.NewGuid().ToString();
var eventData = new OrderPlacedEvent
{
EventId = Guid.NewGuid().ToString(),
CorrelationId = correlationId,
OrderId = order.OrderId,
// ... rest of event
};
// Add trace context to Kafka headers
var headers = new Headers();
headers.Add("traceparent", Encoding.UTF8.GetBytes(activity?.Id ?? ""));
await producer.ProduceAsync("orders", new Message<string, string>
{
Key = order.OrderId,
Value = JsonSerializer.Serialize(eventData),
Headers = headers
});
activity?.AddEvent(new ActivityEvent("Event published to Kafka"));
}
// Consumer with tracing
public async Task ConsumeEvents()
{
foreach (var message in consumer.Consume())
{
var eventData = JsonSerializer.Deserialize<OrderPlacedEvent>(message.Value);
// Extract trace context
var traceParent = message.Headers
.FirstOrDefault(h => h.Key == "traceparent")
?.GetValueBytes();
var parentContext = traceParent != null
? ActivityContext.Parse(Encoding.UTF8.GetString(traceParent), null)
: default;
using var activity = activitySource.StartActivity(
"process_order_event",
ActivityKind.Consumer,
parentContext);
activity?.SetTag("event.type", eventData.EventType);
activity?.SetTag("order.id", eventData.OrderId);
try
{
await ProcessOrder(eventData);
activity?.SetStatus(ActivityStatusCode.Ok);
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
activity?.RecordException(ex);
throw;
}
}
}
3. Correlation IDs - Tracking Requests End-to-End
// ASP.NET Core Middleware for Correlation IDs
public class CorrelationMiddleware
{
private readonly RequestDelegate _next;
private const string CorrelationIdHeader = "X-Correlation-ID";
public CorrelationMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var correlationId = context.Request.Headers[CorrelationIdHeader]
.FirstOrDefault() ?? Guid.NewGuid().ToString();
context.Items["CorrelationId"] = correlationId;
context.Response.Headers[CorrelationIdHeader] = correlationId;
using (Activity.Current?.AddBaggage("correlation_id", correlationId))
{
await _next(context);
}
}
}
// Structured logging with correlation ID
public class CorrelationLogEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CorrelationLogEnricher(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var correlationId = _httpContextAccessor.HttpContext?
.Items["CorrelationId"]?.ToString();
if (correlationId != null)
{
logEvent.AddPropertyIfAbsent(
propertyFactory.CreateProperty("CorrelationId", correlationId));
}
}
}
Debugging Distributed Systems
Common Issue #1: Event Lost
graph TD
A[Event Lost?] --> B{Check Producer Logs}
B -->|No send| C[Producer never sent]
B -->|Sent| D{Check Kafka Topic}
D -->|Not in topic| E[Check ACK settings<br/>Check broker logs]
D -->|In topic| F{Check Consumer}
F -->|Not consuming| G[Check consumer<br/>subscription/offset]
F -->|Consuming| H{Check Processing}
H -->|Not processing| I[Check consumer logs<br/>for errors]
style C fill:#ff7675
style E fill:#fdcb6e
style I fill:#fdcb6e
Debug Script in C#:
public async Task DebugLostEvent(string eventId)
{
Console.WriteLine($"🔍 Debugging event: {eventId}");
// 1. Check producer logs
var producerLogs = await SearchLogs("order-service", eventId);
if (producerLogs.Count == 0)
{
Console.WriteLine("❌ Event never produced");
return;
}
Console.WriteLine("✅ Event was produced");
// 2. Check Kafka topic
var foundInKafka = await SearchKafkaTopic("orders", eventId);
if (!foundInKafka.Found)
{
Console.WriteLine("❌ Event not in Kafka - check producer ACK settings");
var brokerLogs = await GetBrokerLogs(producerLogs.Timestamp);
Console.WriteLine($" Broker logs: {brokerLogs}");
return;
}
Console.WriteLine($"✅ Event in Kafka at offset {foundInKafka.Offset}");
// 3. Check consumer offset
var consumerOffset = await GetConsumerOffset(
"email-service", "orders", foundInKafka.Partition);
if (consumerOffset < foundInKafka.Offset)
{
var lag = foundInKafka.Offset - consumerOffset;
Console.WriteLine($"⚠️ Consumer hasn't reached this offset yet (lag: {lag})");
return;
}
Console.WriteLine("✅ Consumer should have processed this event");
// 4. Check consumer logs
var consumerLogs = await SearchLogs("email-service", eventId);
if (consumerLogs.Count == 0)
{
Console.WriteLine("❌ Consumer didn't process event - check for errors");
var errors = await GetConsumerErrors("email-service");
Console.WriteLine($" Recent errors: {string.Join(", ", errors)}");
return;
}
Console.WriteLine("✅ Consumer processed event");
}
Common Issue #2: High Consumer Lag
public async Task DiagnoseConsumerLag(string consumerGroup, string topic)
{
Console.WriteLine($"📊 Consumer Lag Analysis for {consumerGroup}");
var lagInfo = await GetConsumerLag(consumerGroup, topic);
Console.WriteLine($"Total Lag: {lagInfo.TotalLag} messages");
Console.WriteLine($"Partitions: {lagInfo.PartitionCount}");
Console.WriteLine($"Consumers: {lagInfo.ConsumerCount}");
// Check 1: Not enough consumers?
if (lagInfo.ConsumerCount < lagInfo.PartitionCount)
{
Console.WriteLine($"⚠️ Only {lagInfo.ConsumerCount} consumers " +
$"for {lagInfo.PartitionCount} partitions");
Console.WriteLine(" Recommendation: Add more consumers");
}
// Check 2: Slow processing?
var avgProcessingTime = await GetAvgProcessingTime(consumerGroup);
if (avgProcessingTime > TimeSpan.FromSeconds(1))
{
Console.WriteLine($"⚠️ Slow processing: {avgProcessingTime.TotalSeconds:F2}s average");
Console.WriteLine(" Recommendations:");
Console.WriteLine(" - Optimize processing logic");
Console.WriteLine(" - Add parallelism");
Console.WriteLine(" - Check external dependencies");
}
// Check 3: Frequent rebalancing?
var rebalanceCount = await GetRebalanceCount(consumerGroup, TimeSpan.FromHours(1));
if (rebalanceCount > 5)
{
Console.WriteLine($"⚠️ Frequent rebalancing: {rebalanceCount} times in last hour");
Console.WriteLine(" Check:");
Console.WriteLine(" - session.timeout.ms");
Console.WriteLine(" - max.poll.interval.ms");
Console.WriteLine(" - Consumer stability");
}
// Check 4: Production rate spike?
var productionRate = await GetProductionRate(topic, TimeSpan.FromMinutes(5));
var consumptionRate = await GetConsumptionRate(
consumerGroup, topic, TimeSpan.FromMinutes(5));
if (productionRate > consumptionRate * 1.5)
{
Console.WriteLine($"⚠️ Production outpacing consumption");
Console.WriteLine($" Production: {productionRate:F0} msgs/sec");
Console.WriteLine($" Consumption: {consumptionRate:F0} msgs/sec");
Console.WriteLine(" Recommendation: Scale consumers");
}
}
Alert Rules with Prometheus
alerts.yml:
groups:
- name: consumer_lag
interval: 30s
rules:
- alert: HighConsumerLag
expr: consumer_lag > 10000
for: 5m
labels:
severity: warning
annotations:
summary: "High consumer lag detected"
description: "Consumer group {{ $labels.consumer_group }} has lag of {{ $value }} messages"
- alert: CriticalConsumerLag
expr: consumer_lag > 50000
for: 2m
labels:
severity: critical
annotations:
summary: "Critical consumer lag"
description: "Consumer group {{ $labels.consumer_group }} is severely behind"
- name: processing_errors
interval: 30s
rules:
- alert: HighErrorRate
expr: rate(processing_errors_total[5m]) > 0.05
for: 5m
labels:
severity: warning
annotations:
summary: "High error rate in event processing"
description: "Error rate is {{ $value }} errors/sec for {{ $labels.consumer_group }}"
- name: kafka_health
interval: 30s
rules:
- alert: UnderReplicatedPartitions
expr: kafka_server_replicamanager_underreplicatedpartitions > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Under-replicated partitions detected"
description: "{{ $value }} partitions are under-replicated"
Testing Strategies
Unit Testing
[Fact]
public async Task OrderCommandHandler_PlacesOrder_PublishesEvent()
{
// Arrange
var mockRepository = new Mock<IOrderRepository>();
var mockEventBus = new Mock<IEventBus>();
var handler = new OrderCommandHandler(mockRepository.Object, mockEventBus.Object);
var command = new PlaceOrderCommand
{
CustomerId = "CUST-123",
Items = new List<OrderItem>
{
new() { ProductId = "PROD-001", Quantity = 2, Price = 29.99m }
}
};
// Act
var orderId = await handler.HandlePlaceOrder(command);
// Assert
Assert.NotNull(orderId);
mockRepository.Verify(r => r.SaveAsync(It.IsAny<Order>()), Times.Once);
mockEventBus.Verify(e => e.PublishAsync(
It.Is<OrderPlacedEvent>(evt => evt.OrderId == orderId)),
Times.Once);
}
Integration Testing with Testcontainers
public class KafkaIntegrationTests : IAsyncLifetime
{
private KafkaContainer _kafka;
private IProducer<string, string> _producer;
private IConsumer<string, string> _consumer;
public async Task InitializeAsync()
{
_kafka = new KafkaBuilder().Build();
await _kafka.StartAsync();
var config = new ProducerConfig
{
BootstrapServers = _kafka.GetBootstrapAddress()
};
_producer = new ProducerBuilder<string, string>(config).Build();
var consumerConfig = new ConsumerConfig
{
BootstrapServers = _kafka.GetBootstrapAddress(),
GroupId = "test-group",
AutoOffsetReset = AutoOffsetReset.Earliest
};
_consumer = new ConsumerBuilder<string, string>(consumerConfig).Build();
}
[Fact]
public async Task EndToEnd_ProduceAndConsume_Success()
{
// Arrange
const string topic = "test-topic";
_consumer.Subscribe(topic);
var testEvent = new { OrderId = "ORD-123", Amount = 99.99 };
var message = new Message<string, string>
{
Key = testEvent.OrderId,
Value = JsonSerializer.Serialize(testEvent)
};
// Act
await _producer.ProduceAsync(topic, message);
_producer.Flush(TimeSpan.FromSeconds(10));
var result = _consumer.Consume(TimeSpan.FromSeconds(10));
// Assert
Assert.NotNull(result);
var receivedEvent = JsonSerializer.Deserialize<dynamic>(result.Message.Value);
Assert.Equal("ORD-123", receivedEvent.OrderId.ToString());
}
public async Task DisposeAsync()
{
_producer?.Dispose();
_consumer?.Dispose();
await _kafka.DisposeAsync();
}
}
Load Testing
public class LoadTestResult
{
public TimeSpan TotalDuration { get; set; }
public int TotalRequests { get; set; }
public int SuccessfulRequests { get; set; }
public int FailedRequests { get; set; }
public double AverageDuration { get; set; }
public double P95Duration { get; set; }
public double P99Duration { get; set; }
}
public async Task<LoadTestResult> RunLoadTest(
int requestCount,
bool concurrent)
{
var stopwatch = Stopwatch.StartNew();
var durations = new List<double>();
var successful = 0;
var failed = 0;
if (concurrent)
{
var tasks = Enumerable.Range(0, requestCount)
.Select(async i =>
{
var sw = Stopwatch.StartNew();
try
{
await TriggerEvent($"EVENT-{i}");
Interlocked.Increment(ref successful);
}
catch
{
Interlocked.Increment(ref failed);
}
sw.Stop();
lock (durations)
{
durations.Add(sw.Elapsed.TotalMilliseconds);
}
});
await Task.WhenAll(tasks);
}
else
{
for (int i = 0; i < requestCount; i++)
{
var sw = Stopwatch.StartNew();
try
{
await TriggerEvent($"EVENT-{i}");
successful++;
}
catch
{
failed++;
}
sw.Stop();
durations.Add(sw.Elapsed.TotalMilliseconds);
}
}
stopwatch.Stop();
durations.Sort();
return new LoadTestResult
{
TotalDuration = stopwatch.Elapsed,
TotalRequests = requestCount,
SuccessfulRequests = successful,
FailedRequests = failed,
AverageDuration = durations.Average(),
P95Duration = durations[(int)(durations.Count * 0.95)],
P99Duration = durations[(int)(durations.Count * 0.99)]
};
}
Deployment Strategies
Blue-Green Deployment
# Deploy new version (green) alongside old version (blue)
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-processor-v2
spec:
replicas: 3
selector:
matchLabels:
app: order-processor
version: v2
template:
metadata:
labels:
app: order-processor
version: v2
spec:
containers:
- name: order-processor
image: order-processor:v2
env:
- name: KAFKA_GROUP_ID
value: "order-processor-green"
Process:
- Deploy new version with different consumer group
- Both versions consume same events
- Monitor green deployment
- If healthy, scale down blue
- If issues, rollback to blue instantly
Canary Deployment
// Route percentage of traffic to new version
public class CanaryRouter
{
private readonly int _canaryPercentage;
public CanaryRouter(int canaryPercentage)
{
_canaryPercentage = canaryPercentage;
}
public string GetConsumerGroup(string messageKey)
{
var hash = Math.Abs(messageKey.GetHashCode()) % 100;
return hash < _canaryPercentage
? "processor-canary"
: "processor-stable";
}
}
// Gradually increase: 5% → 10% → 25% → 50% → 100%
Incident Response Runbooks
Runbook: High Consumer Lag
# Runbook: High Consumer Lag Alert
## Symptoms
- Alert: HighConsumerLag
- Consumer group falling behind
- Increasing lag metric
## Impact
- Events processed with delay
- Real-time features degraded
- Potential data loss if retention expires
## Investigation Steps
1. Check current lag:
```bash
kafka-consumer-groups --bootstrap-server localhost:9092 \
--group order-processor --describe
```
2. Check consumer health:
```bash
kubectl get pods -l app=order-processor
kubectl logs -l app=order-processor --tail=100
```
3. Check processing time in Grafana
4. Check rebalancing frequency
## Resolution
### If: Not enough consumers
```bash
kubectl scale deployment/order-processor --replicas=6
```
### If: Slow processing
- Check external dependencies
- Look for slow queries
- Consider adding caching
### If: Frequent rebalancing
- Increase session timeout
- Increase max poll interval
- Fix consumer stability issues
### If: Production spike
- Temporary: Pause non-critical consumers
- Long-term: Implement auto-scaling
## Prevention
- Set up auto-scaling based on lag
- Monitor processing time trends
- Regular load testing
Best Practices Checklist
Development
- ✅ Use correlation IDs for tracing
- ✅ Implement idempotent consumers
- ✅ Use outbox pattern for atomic writes
- ✅ Version all events
- ✅ Make events self-contained
- ✅ Use meaningful event names (past tense)
Operations
- ✅ Monitor consumer lag
- ✅ Alert on high error rates
- ✅ Track end-to-end latency
- ✅ Set up distributed tracing
- ✅ Implement health checks
- ✅ Use circuit breakers for external calls
Reliability
- ✅ Enable producer idempotence
- ✅ Use appropriate ACK level (acks=all)
- ✅ Set proper replication factor (3+)
- ✅ Configure retry policies
- ✅ Implement dead letter queues
- ✅ Regular backup and disaster recovery tests
Performance
- ✅ Tune batch sizes
- ✅ Enable compression
- ✅ Use appropriate partition count
- ✅ Monitor and optimize processing time
- ✅ Scale consumers based on lag
- ✅ Use connection pooling
Security
- ✅ Enable SSL/TLS
- ✅ Implement authentication (SASL)
- ✅ Set up ACLs
- ✅ Encrypt sensitive data
- ✅ Regular security audits
- ✅ Rotate credentials
Part B: Building a Complete Working Demo
Now that we understand the theory, let's build a production-ready demonstration that implements everything we've learned. This demo runs entirely on your laptop and includes:
- 3 C# Microservices communicating via Kafka
- Full Observability Stack (Prometheus, Grafana, Jaeger)
- Interactive React UI to trigger events
- Load Testing to simulate production scenarios
- Complete Docker Compose setup
📦 Complete Implementation: The full working code for this advanced monitoring demo is available in the GitHub repository. Follow the README for step-by-step instructions.
What We'll Build: Event-Driven NAV Calculator
A realistic financial services scenario: calculating Net Asset Value (NAV) for investment funds.
┌─────────────┐ pricing-updates ┌──────────────┐
│ Pricing ├───────────────────────────>│ NAV │
│ Service │ (Kafka) │ Calculator │
└─────────────┘ └──────┬───────┘
│
│ nav-calculated
↓
┌──────────────┐
│ Notification │
│ Service │
└──────────────┘
Flow:
- User triggers pricing for a fund
- Pricing Service publishes
PricingUpdateevent - NAV Calculator (2 instances) consume and calculate NAV
- NAV Calculator publishes
NAVCalculatedevent - Notification Service sends notifications
All observable via:
- Prometheus metrics
- Grafana dashboards
- Jaeger distributed traces
- Structured logs with correlation IDs
Project Structure
event-driven-nav-poc/
├── src/
│ ├── Shared/
│ │ ├── Shared.Observability/ # Reusable metrics, tracing
│ │ └── Shared.Kafka/ # Kafka wrappers with observability
│ ├── Services/
│ │ ├── PricingService/ # Triggers pricing updates
│ │ ├── NavCalculator/ # Calculates NAV (2 instances)
│ │ └── NotificationService/ # Sends notifications
│ └── Tools/
│ └── LoadTester/ # Performance testing tool
├── demo-ui/ # React UI
├── monitoring/ # Prometheus + Grafana config
├── docker-compose.yml # Complete stack
└── README.md
Implementation Highlights
1. Reusable Observability Library
// Shared.Observability/ServiceMetrics.cs
public class ServiceMetrics
{
private readonly string _serviceName;
private readonly Counter _eventsProcessed;
private readonly Histogram _processingDuration;
public ServiceMetrics(string serviceName)
{
_serviceName = serviceName;
_eventsProcessed = Metrics.CreateCounter(
"events_processed_total",
"Total events processed",
new CounterConfiguration
{
LabelNames = new[] { "service", "event_type", "status" }
});
_processingDuration = Metrics.CreateHistogram(
"event_processing_duration_seconds",
"Time to process event",
new HistogramConfiguration
{
LabelNames = new[] { "service", "event_type" },
Buckets = Histogram.ExponentialBuckets(0.001, 2, 10)
});
}
public IDisposable TrackEventProcessing(string eventType)
{
return new EventProcessingTracker(this, eventType);
}
public void RecordEventProcessed(string eventType, string status)
{
_eventsProcessed
.WithLabels(_serviceName, eventType, status)
.Inc();
}
private class EventProcessingTracker : IDisposable
{
private readonly ServiceMetrics _metrics;
private readonly string _eventType;
private readonly Stopwatch _stopwatch;
public EventProcessingTracker(ServiceMetrics metrics, string eventType)
{
_metrics = metrics;
_eventType = eventType;
_stopwatch = Stopwatch.StartNew();
}
public void Dispose()
{
_stopwatch.Stop();
_metrics._processingDuration
.WithLabels(_metrics._serviceName, _eventType)
.Observe(_stopwatch.Elapsed.TotalSeconds);
}
}
}
2. Observable Kafka Wrapper
// Shared.Kafka/ObservableKafkaProducer.cs
public class ObservableKafkaProducer<TKey, TValue> : IDisposable
{
private readonly IProducer<TKey, TValue> _producer;
private readonly ServiceMetrics _metrics;
private readonly ActivitySource _activitySource;
public async Task<DeliveryResult<TKey, TValue>> ProduceAsync(
string topic,
Message<TKey, TValue> message,
string? correlationId = null)
{
using var activity = _activitySource.StartActivity(
"produce_event",
ActivityKind.Producer);
activity?.SetTag("messaging.system", "kafka");
activity?.SetTag("messaging.destination", topic);
activity?.SetTag("correlation_id", correlationId ?? Guid.NewGuid().ToString());
try
{
// Add correlation ID and trace context to headers
message.Headers ??= new Headers();
if (correlationId != null)
{
message.Headers.Add("correlation_id",
Encoding.UTF8.GetBytes(correlationId));
}
if (activity != null)
{
message.Headers.Add("traceparent",
Encoding.UTF8.GetBytes(activity.Id ?? ""));
}
var result = await _producer.ProduceAsync(topic, message);
activity?.SetTag("messaging.kafka.partition", result.Partition.Value);
activity?.SetTag("messaging.kafka.offset", result.Offset.Value);
return result;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
public void Dispose() => _producer?.Dispose();
}
3. NAV Calculator Service (Consumer)
// Services/NavCalculator/NavCalculatorConsumer.cs
public class NavCalculatorConsumer : BackgroundService
{
private readonly ILogger<NavCalculatorConsumer> _logger;
private readonly ServiceMetrics _metrics;
private readonly string _instanceId;
private ObservableKafkaConsumer<string, string> _consumer;
private ObservableKafkaProducer<string, string> _producer;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_consumer.Subscribe("pricing-updates");
_logger.LogInformation(
"🎧 [{InstanceId}] Starting to consume pricing updates...",
_instanceId);
while (!stoppingToken.IsCancellationRequested)
{
await _consumer.ConsumeAsync(async (result) =>
{
var pricingEvent = JsonSerializer
.Deserialize<PricingUpdateEvent>(result.Message.Value);
if (pricingEvent != null)
{
await ProcessPricingUpdate(pricingEvent);
}
}, stoppingToken);
}
}
private async Task ProcessPricingUpdate(PricingUpdateEvent pricingEvent)
{
using var activity = TracingSetup.ActivitySource
.StartActivity("calculate_nav");
activity?.SetTag("fund.id", pricingEvent.FundId);
activity?.SetTag("correlation_id", pricingEvent.CorrelationId);
activity?.SetTag("instance_id", _instanceId);
// Simulate calculation (100-500ms)
var calculationTime = Random.Shared.Next(100, 500);
await Task.Delay(calculationTime);
var totalValue = pricingEvent.Prices.Sum(p => p.Price * p.Quantity);
var sharesOutstanding = Random.Shared.Next(100000, 1000000);
var navPerShare = totalValue / sharesOutstanding;
var navEvent = new NavCalculatedEvent
{
CorrelationId = pricingEvent.CorrelationId,
FundId = pricingEvent.FundId,
NavPerShare = Math.Round(navPerShare, 4),
TotalAssets = totalValue,
SharesOutstanding = sharesOutstanding,
CalculatedAt = DateTime.UtcNow,
CalculatedBy = _instanceId
};
await _producer.ProduceAsync(
"nav-calculated",
new Message<string, string>
{
Key = pricingEvent.FundId,
Value = JsonSerializer.Serialize(navEvent)
},
pricingEvent.CorrelationId);
_logger.LogInformation(
"✅ [{InstanceId}] Calculated NAV for {FundId}: ${Nav:F4}",
_instanceId, pricingEvent.FundId, navPerShare);
}
}
4. Interactive React UI
The UI provides:
- Fund selection buttons to trigger individual calculations
- Month-End simulation button to process all funds simultaneously
- Real-time results display with correlation IDs
- Statistics panel showing success rate, avg duration
- Links to observability tools (Grafana, Jaeger, Prometheus)
Key features:
const triggerPricing = async (fundId, fundName) => {
const startTime = Date.now();
const response = await axios.post(
`${API_BASE_URL}/api/pricing/trigger`,
{ fundId, fundName }
);
const duration = Date.now() - startTime;
setResults(prev => [
{ ...response.data, duration, status: 'success' },
...prev
].slice(0, 20));
};
const triggerMonthEnd = async () => {
// Trigger all funds in parallel (simulates month-end spike)
const promises = funds.map(fund =>
triggerPricing(fund.id, fund.name)
);
await Promise.all(promises);
};
5. Load Testing Tool
// Tools/LoadTester/Program.cs
var rootCommand = new RootCommand("Event-Driven NAV Calculator Load Tester");
var fundCountOption = new Option<int>("--funds", () => 28);
var concurrentOption = new Option<bool>("--concurrent", () => true);
var iterationsOption = new Option<int>("--iterations", () => 1);
rootCommand.SetHandler(async (fundCount, concurrent, iterations) =>
{
await RunLoadTest(fundCount, concurrent, iterations);
}, fundCountOption, concurrentOption, iterationsOption);
// Pretty output with Spectre.Console
AnsiConsole.Write(
new FigletText("Load Tester")
.LeftJustified()
.Color(Color.Blue));
// Progress bar during test
await AnsiConsole.Progress()
.Columns(new ProgressColumn[]
{
new TaskDescriptionColumn(),
new ProgressBarColumn(),
new PercentageColumn(),
new RemainingTimeColumn(),
})
.StartAsync(async ctx => { /* ... */ });
// Results table
var table = new Table();
table.AddColumn("[bold]Metric[/]");
table.AddColumn("[bold]Value[/]");
table.AddRow("Total Duration", $"[green]{totalDuration:F2}s[/]");
table.AddRow("Success Rate", $"{successRate:F2}%");
table.AddRow("P95 Duration", $"{p95:F0}ms");
AnsiConsole.Write(table);
6. Grafana Dashboard
Pre-configured dashboard showing:
- Events processed per second (by service)
- Processing duration (P50, P95, P99)
- Success rate gauge
- Error count
- Average processing time
- Load distribution across NAV calculator instances
7. Docker Compose - Complete Stack
version: '3.8'
services:
kafka:
image: apache/kafka:3.7.0
# KRaft mode - no ZooKeeper needed
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
# ... configuration
prometheus:
image: prom/prometheus:latest
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
grafana:
image: grafana/grafana:latest
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
volumes:
- ./monitoring/grafana-dashboards:/etc/grafana/provisioning/dashboards
jaeger:
image: jaegertracing/all-in-one:latest
pricing-service:
build:
context: .
dockerfile: src/Services/PricingService/Dockerfile
nav-calculator-1:
build:
context: .
dockerfile: src/Services/NavCalculator/Dockerfile
environment:
- INSTANCE_ID=nav-calculator-1
nav-calculator-2:
build:
context: .
dockerfile: src/Services/NavCalculator/Dockerfile
environment:
- INSTANCE_ID=nav-calculator-2
notification-service:
build:
context: .
dockerfile: src/Services/NotificationService/Dockerfile
demo-ui:
build:
context: ./demo-ui
Running the Demo
Quick Start
# Clone repository
git clone https://github.com/YOUR_USERNAME/event-driven-nav-poc
cd event-driven-nav-poc
# Start everything
docker-compose up --build
# Wait ~2 minutes, then open:
# - http://localhost:3001 (Demo UI)
# - http://localhost:3000 (Grafana - admin/admin)
# - http://localhost:16686 (Jaeger)
What to Try
-
Single Fund Calculation
- Click any fund button in the UI
- Watch logs in terminal
- Check Grafana for metrics spike
- Search Jaeger by correlation ID
-
Month-End Spike
- Click "Simulate Month-End"
- Watch 5 funds process simultaneously
- Observe load distribution across 2 NAV calculator instances
- Check P95 latency in Grafana
-
Load Testing
cd src/Tools/LoadTester
dotnet run -- --funds 28 --concurrent true --iterations 3
Output:
┌─────────────────┬──────────────┐
│ Metric │ Value │
├─────────────────┼──────────────┤
│ Total Duration │ 2.34s │
│ Successful │ 28 │
│ P95 Duration │ 487ms │
│ Throughput │ 11.97 f/s │
└─────────────────┴──────────────┘
-
Distributed Tracing
- Trigger an event from UI
- Copy correlation ID from result
- Paste into Jaeger search
- See complete trace across all 3 services
-
Simulate Failure
# Stop one NAV calculator instance
docker stop nav-calculator-2
# Trigger events - watch other instance handle all load
# Check Grafana for increased load on nav-calculator-1
# Restart
docker start nav-calculator-2
Key Learnings from This Demo
1. Observability is Essential
Without Prometheus + Grafana + Jaeger, you're flying blind:
- Metrics show what is happening
- Logs explain why it happened
- Traces reveal where in the flow it happened
2. Correlation IDs Enable Debugging
Following a single request through multiple services is impossible without correlation IDs. With them, you can:
- Search logs across all services
- Find the exact trace in Jaeger
- Debug issues that span multiple hops
3. Horizontal Scaling Just Works
When we run 2 NAV calculator instances:
- Kafka automatically distributes partitions
- Load balances across instances
- Rebalances if one dies
4. Load Testing Reveals Bottlenecks
Running 28 concurrent requests shows:
- Where your slowest dependencies are
- If your consumers can keep up
- What happens under month-end load
5. Production Patterns Matter
This demo implements real production patterns:
- Idempotent producers
- At-least-once delivery
- Structured logging
- Health checks
- Graceful shutdown
Adapting This for Your Domain
This demo uses NAV calculation, but the patterns apply to any domain:
E-commerce:
- Order Service → Inventory Service → Shipping Service
IoT:
- Device Telemetry → Processing Service → Alert Service
Financial Services:
- Trade Execution → Risk Check → Settlement
The architecture is identical:
- Event producer publishes to Kafka
- Multiple consumers process in parallel
- Each consumer publishes downstream events
- Everything is observable via Prometheus/Grafana/Jaeger
Best Practices Demonstrated
✅ Separation of Concerns - Each service has one job
✅ Observability First - Metrics, logs, traces from day 1
✅ Horizontal Scalability - Easy to add more instances
✅ Resilience - Services can fail independently
✅ Testability - Load testing built in
✅ Developer Experience - One command to start everything
✅ Production Ready - Implements real production patterns
Next Steps
For Learning:
- Modify the demo to add another service
- Change the event schema and see schema evolution
- Implement a dead letter queue
- Add a saga pattern
For Production:
- Add Kubernetes manifests
- Implement auto-scaling based on lag
- Set up CI/CD pipeline
- Add authentication/authorization
- Implement data encryption
Conclusion
Operating event-driven systems in production requires:
Theory (Part A):
- Understanding the three pillars of observability
- Knowing how to debug distributed systems
- Having comprehensive testing strategies
- Planning deployment approaches
- Preparing incident response procedures
Practice (Part B):
- Actually building the observability stack
- Instrumenting your code properly
- Testing under realistic load
- Experiencing failures and recovery
- Iterating based on real metrics
This guide gives you both. The theory ensures you understand why things work a certain way. The working demo lets you see it in action and experiment safely.
You're now ready to build and operate production event-driven systems! 🚀
Remember:
- Start simple, add complexity as needed
- Monitor from day one
- Test failure scenarios
- Document everything
- Learn from incidents
The full source code is available at: GitHub Repository
Good luck with your event-driven journey!
Additional Resources
Built with ❤️ for the Event-Driven Architecture blog series
Questions about this post?
Send a message and I'll get back to you.