12 KiB
12 KiB
Getting Started with Event Streaming
Your first event stream - from installation to publishing and consuming events.
Installation
Install NuGet Package
# For development (in-memory storage)
dotnet add package Svrnty.CQRS.Events
# For production (PostgreSQL storage)
dotnet add package Svrnty.CQRS.Events.PostgreSQL
Configuration
In-Memory (Development)
using Svrnty.CQRS.Events;
var builder = WebApplication.CreateBuilder(args);
// Register in-memory event streaming
builder.Services.AddInMemoryEventStreaming();
var app = builder.Build();
app.Run();
PostgreSQL (Production)
appsettings.json:
{
"ConnectionStrings": {
"EventStore": "Host=localhost;Database=eventstore;Username=postgres;Password=postgres"
}
}
Program.cs:
using Svrnty.CQRS.Events.PostgreSQL;
var builder = WebApplication.CreateBuilder(args);
// Register PostgreSQL event streaming
builder.Services.AddPostgresEventStreaming(
builder.Configuration.GetConnectionString("EventStore"));
var app = builder.Build();
app.Run();
Database Migration
PostgreSQL storage automatically migrates the database on startup:
# Start PostgreSQL
docker run -d --name postgres \
-e POSTGRES_PASSWORD=postgres \
-p 5432:5432 \
postgres:16
# Run application - tables created automatically
dotnet run
Define Events
Events are immutable records describing facts:
public record UserRegisteredEvent
{
public int UserId { get; init; }
public string Email { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public DateTimeOffset RegisteredAt { get; init; }
}
public record EmailVerifiedEvent
{
public int UserId { get; init; }
public string Email { get; init; } = string.Empty;
public DateTimeOffset VerifiedAt { get; init; }
}
Publishing Events
Append to Persistent Stream
public class UserService
{
private readonly IEventStreamStore _eventStore;
public UserService(IEventStreamStore eventStore)
{
_eventStore = eventStore;
}
public async Task RegisterUserAsync(string email, string name)
{
var userId = GenerateUserId();
var @event = new UserRegisteredEvent
{
UserId = userId,
Email = email,
Name = name,
RegisteredAt = DateTimeOffset.UtcNow
};
// Append to persistent stream
await _eventStore.AppendAsync(
streamName: "users",
events: new[] { @event });
Console.WriteLine($"User registered: {userId}");
}
}
Publish Multiple Events
public async Task RegisterAndVerifyUserAsync(string email, string name)
{
var userId = GenerateUserId();
var now = DateTimeOffset.UtcNow;
// Publish multiple events atomically
await _eventStore.AppendAsync("users", new object[]
{
new UserRegisteredEvent
{
UserId = userId,
Email = email,
Name = name,
RegisteredAt = now
},
new EmailVerifiedEvent
{
UserId = userId,
Email = email,
VerifiedAt = now
}
});
}
Enqueue to Ephemeral Stream
For background jobs and notifications:
public async Task SendWelcomeEmailAsync(int userId, string email)
{
var command = new SendEmailCommand
{
To = email,
Subject = "Welcome!",
Body = "Thanks for registering."
};
// Enqueue to ephemeral stream (message queue)
await _eventStore.EnqueueAsync(
streamName: "email-queue",
message: command);
}
Consuming Events
Read from Persistent Stream
public class EventConsumer
{
private readonly IEventStreamStore _eventStore;
public EventConsumer(IEventStreamStore eventStore)
{
_eventStore = eventStore;
}
public async Task ProcessUserEventsAsync()
{
// Read all events from beginning
await foreach (var storedEvent in _eventStore.ReadStreamAsync(
streamName: "users",
fromOffset: 0))
{
Console.WriteLine($"Event {storedEvent.Offset}: {storedEvent.EventType}");
// Deserialize event
var eventData = JsonSerializer.Deserialize(
storedEvent.Data,
Type.GetType(storedEvent.EventType));
// Process event
await ProcessEventAsync(eventData);
}
}
private async Task ProcessEventAsync(object? eventData)
{
switch (eventData)
{
case UserRegisteredEvent registered:
Console.WriteLine($"User {registered.UserId} registered: {registered.Email}");
break;
case EmailVerifiedEvent verified:
Console.WriteLine($"Email verified: {verified.Email}");
break;
}
}
}
Read from Specific Offset
// Resume from last processed offset
long lastProcessedOffset = 1000;
await foreach (var @event in _eventStore.ReadStreamAsync("users", fromOffset: lastProcessedOffset + 1))
{
await ProcessEventAsync(@event);
lastProcessedOffset = @event.Offset;
// Save checkpoint
await SaveCheckpointAsync(lastProcessedOffset);
}
Dequeue from Ephemeral Stream
public class EmailWorker : BackgroundService
{
private readonly IEventStreamStore _eventStore;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
// Dequeue message with 5-minute visibility timeout
var message = await _eventStore.DequeueAsync(
streamName: "email-queue",
visibilityTimeout: TimeSpan.FromMinutes(5),
cancellationToken: stoppingToken);
if (message == null)
{
// No messages available, wait
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
continue;
}
// Process message
var command = JsonSerializer.Deserialize<SendEmailCommand>(message.Data);
await SendEmailAsync(command);
// Acknowledge successful processing
await _eventStore.AcknowledgeAsync("email-queue", message.MessageId);
}
catch (Exception ex)
{
// Error - message will be redelivered after visibility timeout
Console.WriteLine($"Error processing message: {ex.Message}");
}
}
}
}
Complete Example
Define Events:
public record OrderPlacedEvent
{
public int OrderId { get; init; }
public string CustomerName { get; init; } = string.Empty;
public decimal TotalAmount { get; init; }
public List<OrderItem> Items { get; init; } = new();
}
public record OrderItem
{
public int ProductId { get; init; }
public int Quantity { get; init; }
public decimal Price { get; init; }
}
Publisher Service:
public class OrderService
{
private readonly IEventStreamStore _eventStore;
public OrderService(IEventStreamStore eventStore)
{
_eventStore = eventStore;
}
public async Task<int> PlaceOrderAsync(string customerName, List<OrderItem> items)
{
var orderId = GenerateOrderId();
var @event = new OrderPlacedEvent
{
OrderId = orderId,
CustomerName = customerName,
TotalAmount = items.Sum(i => i.Price * i.Quantity),
Items = items
};
// Append to persistent stream
await _eventStore.AppendAsync("orders", new[] { @event });
// Enqueue notification
await _eventStore.EnqueueAsync("order-notifications", new
{
OrderId = orderId,
CustomerName = customerName,
Type = "OrderPlaced"
});
return orderId;
}
}
Consumer Worker:
public class OrderNotificationWorker : BackgroundService
{
private readonly IEventStreamStore _eventStore;
private readonly ILogger<OrderNotificationWorker> _logger;
public OrderNotificationWorker(
IEventStreamStore eventStore,
ILogger<OrderNotificationWorker> logger)
{
_eventStore = eventStore;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Order notification worker started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var message = await _eventStore.DequeueAsync(
"order-notifications",
TimeSpan.FromMinutes(5),
stoppingToken);
if (message == null)
{
await Task.Delay(100, stoppingToken);
continue;
}
// Process notification
var notification = JsonSerializer.Deserialize<dynamic>(message.Data);
_logger.LogInformation("Sending notification for order {OrderId}", notification.OrderId);
await SendNotificationAsync(notification);
await _eventStore.AcknowledgeAsync("order-notifications", message.MessageId);
}
catch (OperationCanceledException)
{
// Shutdown requested
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing notification");
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
_logger.LogInformation("Order notification worker stopped");
}
private async Task SendNotificationAsync(dynamic notification)
{
// Send email, SMS, push notification, etc.
await Task.Delay(100);
}
}
Registration:
var builder = WebApplication.CreateBuilder(args);
// Event streaming
builder.Services.AddPostgresEventStreaming(
builder.Configuration.GetConnectionString("EventStore"));
// Services
builder.Services.AddScoped<OrderService>();
// Background workers
builder.Services.AddHostedService<OrderNotificationWorker>();
var app = builder.Build();
app.Run();
Testing
Unit Testing with In-Memory Store
public class OrderServiceTests
{
[Fact]
public async Task PlaceOrder_PublishesEvent()
{
// Arrange
var services = new ServiceCollection();
services.AddInMemoryEventStreaming();
var provider = services.BuildServiceProvider();
var store = provider.GetRequiredService<IEventStreamStore>();
var service = new OrderService(store);
// Act
var orderId = await service.PlaceOrderAsync("John Doe", new List<OrderItem>
{
new() { ProductId = 1, Quantity = 2, Price = 10.00m }
});
// Assert
var events = new List<StoredEvent>();
await foreach (var evt in store.ReadStreamAsync("orders", 0))
{
events.Add(evt);
}
Assert.Single(events);
Assert.Equal("OrderPlacedEvent", events[0].EventType);
}
}
Next Steps
- Learn about Persistent Streams for event sourcing
- Explore Ephemeral Streams for message queues
- Design events with Events and Workflows
- Configure Subscriptions for consuming events
- Use Consumer Groups for load balancing
Best Practices
✅ DO
- Use persistent streams for audit logs and event sourcing
- Use ephemeral streams for background jobs and notifications
- Acknowledge messages after successful processing
- Handle deserialization errors gracefully
- Use correlation IDs for distributed tracing
- Version your events for schema evolution
❌ DON'T
- Don't modify events after appending
- Don't process messages without acknowledging or nacking
- Don't store large payloads in events (use references)
- Don't forget error handling in consumers
- Don't block event processing with synchronous I/O
- Don't skip checkpointing in long-running consumers