Event Sourcing in .NET: Full Audit History with Marten
Implement event sourcing in .NET using Marten and PostgreSQL. Covers the event store concept, appending and replaying events, projections, snapshots, versioning, and when event sourcing is worth the complexity.
What is Event Sourcing?
Traditional persistence: save the current state. A row in the database = what something looks like right now.
Event sourcing: save every change as an event. The current state is derived by replaying events.
Traditional:
Orders table → { id: 1, status: "Shipped", total: 150.00 }
Event Sourced:
Events stream for order-1:
1. OrderCreated { customerId, lines }
2. LineAdded { productId, quantity }
3. OrderSubmitted { submittedAt }
4. OrderShipped { carrier, trackingNo }
Current state = replay all four eventsWhat you gain:
- Full audit trail — every state change is recorded, nothing is lost
- Temporal queries — "what did this order look like on Tuesday?"
- Event-driven by nature — publish events to other systems without dual-writes
- Debugging — reproduce any past state
What it costs:
- Read complexity — projections needed for queries
- Schema migration is harder — old events must remain valid
- Operational overhead — projection rebuilds, snapshot management
Use event sourcing when:
- Audit trail is a business requirement (financial, healthcare, compliance)
- You need the full history of how state evolved
- Multiple systems need to react to domain changes
Setup with Marten
Marten uses PostgreSQL as an event store (and document store). No separate EventStoreDB needed.
dotnet add package Marten
dotnet add package Weasel.Postgresql// Program.cs
builder.Services.AddMarten(options =>
{
options.Connection(builder.Configuration.GetConnectionString("PostgreSQL")!);
options.Events.AddEventType<OrderCreated>();
options.Events.AddEventType<LineAdded>();
options.Events.AddEventType<OrderSubmitted>();
options.Events.AddEventType<OrderCancelled>();
options.Events.AddEventType<OrderShipped>();
// Register projections
options.Projections.Add<OrderSummaryProjection>(ProjectionLifecycle.Inline);
options.Projections.Add<CustomerOrderHistoryProjection>(ProjectionLifecycle.Async);
}).UseLightweightSessions();Events
Events are immutable records of what happened. Use records.
// Domain events — append-only, never change after creation
public record OrderCreated(
Guid OrderId,
Guid CustomerId,
DateTime CreatedAt);
public record LineAdded(
Guid OrderId,
Guid ProductId,
int Quantity,
decimal UnitPrice);
public record OrderSubmitted(
Guid OrderId,
DateTime SubmittedAt,
decimal Total);
public record OrderShipped(
Guid OrderId,
string Carrier,
string TrackingNumber,
DateTime ShippedAt);
public record OrderCancelled(
Guid OrderId,
string Reason,
DateTime CancelledAt);Aggregate
The aggregate replays events to reconstruct its current state:
public class Order
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; } = OrderStatus.Pending;
public List<OrderLine> Lines { get; private set; } = new();
public decimal Total { get; private set; }
// Marten requires an empty constructor or a static Create method
public static Order Create(IEnumerable<object> events)
{
var order = new Order();
foreach (var e in events) order.Apply(e);
return order;
}
// Apply methods — each event type updates state
public void Apply(OrderCreated e)
{
Id = e.OrderId;
CustomerId = e.CustomerId;
Status = OrderStatus.Pending;
}
public void Apply(LineAdded e)
{
Lines.Add(new OrderLine(e.ProductId, e.Quantity, e.UnitPrice));
Total += e.Quantity * e.UnitPrice;
}
public void Apply(OrderSubmitted e)
{
Status = OrderStatus.Submitted;
Total = e.Total;
}
public void Apply(OrderShipped e) => Status = OrderStatus.Shipped;
public void Apply(OrderCancelled e) => Status = OrderStatus.Cancelled;
// Business methods — validate and return new events
public IEnumerable<object> Submit()
{
if (Status != OrderStatus.Pending)
throw new DomainException($"Cannot submit order in {Status} status.");
if (!Lines.Any())
throw new DomainException("Cannot submit empty order.");
yield return new OrderSubmitted(Id, DateTime.UtcNow, Total);
}
public IEnumerable<object> Cancel(string reason)
{
if (Status == OrderStatus.Shipped)
throw new DomainException("Cannot cancel a shipped order.");
yield return new OrderCancelled(Id, reason, DateTime.UtcNow);
}
}Writing Events
public class OrderService
{
private readonly IDocumentSession _session;
public OrderService(IDocumentSession session) => _session = session;
public async Task<Guid> CreateOrderAsync(CreateOrderRequest req, CancellationToken ct)
{
var orderId = Guid.NewGuid();
// Start a new event stream
_session.Events.StartStream<Order>(orderId,
new OrderCreated(orderId, req.CustomerId, DateTime.UtcNow));
foreach (var line in req.Lines)
_session.Events.Append(orderId,
new LineAdded(orderId, line.ProductId, line.Quantity, line.UnitPrice));
await _session.SaveChangesAsync(ct);
return orderId;
}
public async Task SubmitOrderAsync(Guid orderId, CancellationToken ct)
{
// Reload aggregate by replaying events
var order = await _session.Events.AggregateStreamAsync<Order>(orderId, token: ct)
?? throw new NotFoundException("Order", orderId);
var newEvents = order.Submit().ToArray();
// Optimistic concurrency — version must match
_session.Events.Append(orderId, order.Version, newEvents);
await _session.SaveChangesAsync(ct);
}
}Projections (Read Models)
Events give you the history but are awkward to query. Projections materialise read-optimised views.
// Inline projection — updated synchronously with SaveChangesAsync
public class OrderSummaryProjection : SingleStreamProjection<OrderSummary>
{
public OrderSummary Create(OrderCreated e) => new OrderSummary
{
Id = e.OrderId,
CustomerId = e.CustomerId,
Status = "Pending",
CreatedAt = e.CreatedAt
};
public void Apply(LineAdded e, OrderSummary summary)
{
summary.LineCount++;
summary.Total += e.Quantity * e.UnitPrice;
}
public void Apply(OrderSubmitted e, OrderSummary summary)
{
summary.Status = "Submitted";
summary.SubmittedAt = e.SubmittedAt;
summary.Total = e.Total;
}
public void Apply(OrderShipped e, OrderSummary summary)
=> summary.Status = "Shipped";
public void Apply(OrderCancelled e, OrderSummary summary)
=> summary.Status = "Cancelled";
}
// Read model — document stored in PostgreSQL
public class OrderSummary
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public string Status { get; set; } = "";
public int LineCount { get; set; }
public decimal Total { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? SubmittedAt { get; set; }
}Querying the projection:
// Query the materialised view — fast, indexed
var summaries = await _session.Query<OrderSummary>()
.Where(s => s.CustomerId == customerId && s.Status == "Pending")
.ToListAsync(ct);Snapshots
Replaying 10,000 events per aggregate is slow. Snapshots save the aggregate state every N events.
options.Events.UseAggregatorLookup(AggregationLookupStrategy.UsePublicApply);
options.Events.Projections.SelfAggregate<Order>(ProjectionLifecycle.Inline);
options.Events.Projections.Snapshot<Order>(new SnapshotOptions
{
SnapshotThreshold = 50 // snapshot every 50 events
});Marten loads the nearest snapshot and replays only events after it.
Event Versioning
Events are permanent. You can't change them. Handle schema evolution with:
// Option 1: Upcasters — transform old event format to new
public class OrderCreatedV1UpcasterPipeline : IEventUpcaster
{
public Type Handles => typeof(OrderCreatedV1);
public IEvent Upcast(IEvent<OrderCreatedV1> input)
{
// Transform V1 → current format
var v2 = new OrderCreated(
input.Data.OrderId,
input.Data.UserId, // renamed from UserId to CustomerId
input.Data.Timestamp);
return input.WithData(v2);
}
}
// Option 2: New event types for breaking changes
// Never modify existing event properties — add new event types instead
public record OrderCreatedV2(
Guid OrderId,
Guid CustomerId,
string Source, // new field
DateTime CreatedAt);Interview Questions
Q: What is the difference between event sourcing and event-driven architecture? Event-driven architecture uses events for communication between services. Event sourcing uses events as the primary storage mechanism — the state is derived by replaying events, not stored directly. You can have event-driven architecture without event sourcing (and usually do).
Q: What is a projection in event sourcing? A read model built from events. Events are the source of truth but are hard to query efficiently. Projections replay events and materialise query-optimised views (e.g., an OrderSummary table). Projections can be rebuilt at any time by replaying the event stream.
Q: How do you handle schema changes when events are immutable? Upcasters — convert old event versions to the current format when loading. Write new event types for breaking changes rather than modifying existing ones. Additive changes (new optional fields) are backwards compatible. Never rename or remove fields from existing event types.
Q: What is the biggest operational challenge with event sourcing? Projection rebuilds — when you add a new projection or change an existing one, you must replay the entire event history. For large streams, this can take hours. Mitigation: snapshots (replay from nearest snapshot), async projections (rebuild in background without blocking), partitioned streams.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.