The Hard Parts of Event Sourcing: GDPR, Unique Constraints, and Dynamic Consistency Boundaries
Keep personal data out of events and encrypt it with crypto-shredding when it must stay; protect cross-stream uniqueness with a natural-key stream id or an Inline projection with a unique index, and rules spanning two aggregates with a Dynamic Consistency Boundary. AES-GCM crypto-shredding with Marten 9, a race between two concurrent registrations, a campaign quota with DcbConcurrencyException, and not calling external services on replay.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore got a new feature: a "Notify me when it's back" button on the page of a sold-out book. The request is written as an event to the stock item's stream, and in the first version the event also carries the customer's email address:
// Bad: the email address is inside the event, in plain text
public sealed record RestockAlertRequestedWithEmail(StockItemId StockItemId, CustomerId CustomerId, string Email, DateTimeOffset At)
: StockEvent(StockItemId, At);
Three months later Ada sends an email: she has closed her account and wants her personal data deleted. That is the "right to be forgotten" in Article 17 of the GDPR (General Data Protection Regulation), and in Turkey the deletion and destruction obligation in Article 7 of the KVKK (Law No. 6698 on the Protection of Personal Data). But the event store is append-only, and the whole message of post 08 was "do not change the past". What do you do? Delete the stream entirely (the stock ledger's history goes with it), change the event with an UPDATE, or something else?
This post covers three hard parts of event sourcing that do not fit inside a single stream: personal data in immutable events, a uniqueness rule that spans two streams, and consistency that crosses an aggregate boundary. At the end we will also look at why replay must not touch the outside world.
Keeping Personal Data Out of Events
The best solution is not to have the problem at all. The event carries the identity the decision needs; contact information such as an email address lives in an ordinary record that can be deleted:
// Good (the default): the event carries only the identity, contact details live in a deletable document
public sealed record RestockAlertRequested(StockItemId StockItemId, CustomerId CustomerId, DateTimeOffset At)
: StockEvent(StockItemId, At);
public sealed record CustomerContact(Guid Id, string Email);
When Ada wants to be deleted, the CustomerContact document is deleted. The events stay in place and still make sense: "a customer asked to be notified about this book" is valuable for stock planning, but it no longer carries anyone's email address. The code that sends the notification looks up contact details by the event's CustomerId at that moment; if it finds nothing, it does not send. None of the stock events in this series carries personal data, and that is a deliberate decision. The most expensive form of the "putting a domain object in an event" pitfall from post 06 is embedding a Customer entity in an event as is.
Crypto-Shredding: Deleting the Key
Sometimes personal data has to stay in the event: if events are archived and exported to another system, or if the address at that moment must legally be on record. That is where crypto-shredding comes in: you encrypt the personal field with a key specific to the customer and keep the key in a separate, deletable place. When a deletion request arrives, only the key is deleted; the ciphertext in the event can no longer be read by anyone.
// If personal data has to stay in the event: encrypt it with a per-customer key
public sealed record EncryptedValue(string Nonce, string CipherText, string Tag);
public sealed record RestockAlertRequestedEncrypted(StockItemId StockItemId, CustomerId CustomerId, EncryptedValue Email, DateTimeOffset At)
: StockEvent(StockItemId, At);
public sealed record CustomerKey(Guid Id, byte[] Key);
public sealed class PersonalDataProtector(IDocumentStore store)
{
public async Task<EncryptedValue> EncryptAsync(CustomerId customer, string plainText)
{
await using IDocumentSession session = store.LightweightSession();
CustomerKey? key = await session.LoadAsync<CustomerKey>(customer.Value);
if (key is null)
{
key = new CustomerKey(customer.Value, RandomNumberGenerator.GetBytes(32));
session.Store(key);
await session.SaveChangesAsync();
}
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
byte[] plain = Encoding.UTF8.GetBytes(plainText);
byte[] cipher = new byte[plain.Length];
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize];
using AesGcm aes = new(key.Key, tag.Length);
aes.Encrypt(nonce, plain, cipher, tag);
return new EncryptedValue(Convert.ToBase64String(nonce), Convert.ToBase64String(cipher), Convert.ToBase64String(tag));
}
public async Task<string?> DecryptAsync(IQuerySession session, CustomerId customer, EncryptedValue value)
{
// If the key was deleted, the data can no longer be read: crypto-shredding
CustomerKey? key = await session.LoadAsync<CustomerKey>(customer.Value);
if (key is null)
return null;
byte[] cipher = Convert.FromBase64String(value.CipherText);
byte[] plain = new byte[cipher.Length];
using AesGcm aes = new(key.Key, AesGcm.TagByteSizes.MaxSize);
aes.Decrypt(Convert.FromBase64String(value.Nonce), cipher, Convert.FromBase64String(value.Tag), plain);
return Encoding.UTF8.GetString(plain);
}
public async Task ForgetAsync(CustomerId customer)
{
await using IDocumentSession session = store.LightweightSession();
session.Delete<CustomerKey>(customer.Value);
await session.SaveChangesAsync();
}
}
The GCM (Galois/Counter Mode) mode of AES (Advanced Encryption Standard) comes with .NET as System.Security.Cryptography.AesGcm; we generate a new nonce for every encryption, and thanks to the tag a tampered ciphertext fails in Decrypt. The scenario: first the event from the opening with a plain email, then encrypted requests for Ada and Alan.
await using (IDocumentSession session = store.LightweightSession())
{
session.Events.Append(dune.Value, new RestockAlertRequestedWithEmail(dune, ada, "ada@example.com", at));
await session.SaveChangesAsync();
}
Console.WriteLine(await EventJson(dune.Value, 2));
PersonalDataProtector protector = new(store);
await using (IDocumentSession session = store.LightweightSession())
{
session.Events.Append(dune.Value,
new RestockAlertRequestedEncrypted(dune, ada, await protector.EncryptAsync(ada, "ada@example.com"), at),
new RestockAlertRequestedEncrypted(dune, alan, await protector.EncryptAsync(alan, "alan@example.com"), at));
await session.SaveChangesAsync();
}
Console.WriteLine(await EventJson(dune.Value, 3));
await PrintAlerts("Before");
// Ada exercises her "right to be forgotten": only her key is deleted, the events stay in place
await protector.ForgetAsync(ada);
await PrintAlerts("After forgetting Ada");
async Task PrintAlerts(string label)
{
await using IQuerySession query = store.QuerySession();
IReadOnlyList<IEvent> events = await query.Events.FetchStreamAsync(dune.Value);
IEnumerable<RestockAlertRequestedEncrypted> alerts = events.Select(e => e.Data).OfType<RestockAlertRequestedEncrypted>();
List<string> emails = [];
foreach (RestockAlertRequestedEncrypted alert in alerts)
emails.Add(await protector.DecryptAsync(query, alert.CustomerId, alert.Email) ?? "[erased]");
Console.WriteLine($"{label}: {string.Join(", ", emails)} (events in stream: {events.Count})");
}
{"At": "2026-09-01T09:00:00+00:00", "Email": "ada@example.com", "CustomerId": {"Value": "01a1b854-93ad-76ea-8f86-0bd17aa679f7"}, "StockItemId": {"Value": "01a1b854-92b0-7c26-a538-2965046ea85c"}}
{"At": "2026-09-01T09:00:00+00:00", "Email": {"Tag": "yPo+f+Bcv/3eVYsScwH2Ew==", "Nonce": "GCk8uSzQ3NpAegk1", "CipherText": "wTEZpzY8shTskyY+jALq"}, "CustomerId": {"Value": "01a1b854-93ad-76ea-8f86-0bd17aa679f7"}, "StockItemId": {"Value": "01a1b854-92b0-7c26-a538-2965046ea85c"}}
Before: ada@example.com, alan@example.com (events in stream: 4)
After forgetting Ada: [erased], alan@example.com (events in stream: 4)
All four events in the stream are still in place; Ada's encrypted email is now [erased]. But look at the first line: the old event written in plain text still says ada@example.com. Crypto-shredding only protects data that was encrypted from the start; for personal data that was once written in the clear, two paths remain. One is the event data masking Marten has offered since 7.31: you define a masking rule with AddMaskingRuleForProtectedInformation<T> and rewrite the events of the streams you choose in place with store.Advanced.ApplyEventDataMasking(...). That is a legally required, narrowly scoped instance of the in-place migration we called a last resort in post 08. The other is deleting the stream, which takes the stock ledger's history with it.
Crypto-shredding has its own costs too. The key table is now a critical component: if it is lost, every customer's data is gone; if it is backed up, the deleted key lives on in the backup (backup retention must be part of the deletion process). If read models hold decrypted data, they also need to be cleaned or rebuilt. And the legal question remains: whether encrypted data whose key has been deleted counts as "deleted" depends on the regulation the application is subject to and on the legal team's view. Marten's documentation does not offer crypto-shredding as a built-in feature; you build it on the application side as above.
Cross-Stream Invariants: Uniqueness
One of the stock ledger's assumptions: there is a single warehouse and a single StockItem is opened per book. This rule does not live inside one stream but across streams. What happens if you leave it alone?
// Two separate requests for the same book, two different StockItemIds
BookId dune = new(Guid.CreateVersion7());
await Task.WhenAll(
handler.HandleAsync(new RegisterStockItem(new StockItemId(Guid.CreateVersion7()), dune, at)),
handler.HandleAsync(new RegisterStockItem(new StockItemId(Guid.CreateVersion7()), dune, at)));
Stock items registered for the same book: 2
Two streams, two separate consistency boundaries; each checked its own version and both were right. Two stock items now exist for the same book, and its copies will be split between them. Saying "first look at the read model to see whether this book has a stock item" does not solve it either: an async read model will not have been updated in the few milliseconds between the two requests. Only a mechanism that checks uniqueness atomically at a single point protects it. There are three ways.
Making the natural key the stream id
If the rule is "one stock item per book", the stock item's id can be the book's id:
// The stream id is the book's own id: a second stream for the same book cannot be opened
BookId dune = new(Guid.CreateVersion7());
StockItemId stockItemId = new(dune.Value);
Task<IReadOnlyList<StockEvent>>[] attempts =
[
handler.HandleAsync(new RegisterStockItem(stockItemId, dune, at)),
handler.HandleAsync(new RegisterStockItem(stockItemId, dune, at))
];
foreach (Task<IReadOnlyList<StockEvent>> attempt in attempts)
{
try
{
Console.WriteLine($"Registered: {(await attempt).Count} event(s)");
}
catch (Exception ex) when (ex is ConcurrencyException or Marten.Exceptions.ExistingStreamIdCollisionException or DomainException)
{
Console.WriteLine($"Rejected: {ex.GetType().Name}");
}
}
Registered: 1 event(s)
Rejected: ExistingStreamIdCollisionException
The second request hit the primary key of mt_streams, which we saw in post 06. This is the cheapest solution, but it only works when the unique thing is the stream's own identity. For a rule like "one account per email address" you would not want the email to be the stream id: it can change, and it is personal data.
Reservation pattern: an Inline projection with a unique index
The second way is to "reserve" the unique value in a table in the same transaction as the events. In Marten that is an Inline projection with a unique index:
// One stock item per book: Inline projection + unique index
public sealed record StockItemByBook(Guid Id, Guid BookId);
public sealed class StockItemByBookProjection : SingleStreamProjection<StockItemByBook, Guid>
{
public override StockItemByBook? Evolve(StockItemByBook? snapshot, Guid id, IEvent e) =>
e.Data is StockItemRegistered r ? new StockItemByBook(id, r.BookId.Value) : snapshot;
}
opts.Projections.Add(new StockItemByBookProjection(), ProjectionLifecycle.Inline);
opts.Schema.For<StockItemByBook>().UniqueIndex(x => x.BookId);
Rejected: DocumentAlreadyExistsException (23505 mt_doc_stockitembybook_uidx_book_id)
Registered: 1 event(s)
This time the two requests had different stream ids, but both tried to write the StockItemByBook document in the same transaction as their events, and the unique index rejected the second one. Because the event was in the same transaction as the projection, it was rolled back too. In post 07 we counted an Inline projection's error failing the command as a downside; here it is exactly what we want. Which one wins depends on the race: in the output, the first task lost.
In systems where the event store and the uniqueness record live in different databases (for example KurrentDB plus a SQL (Structured Query Language) table), there is no single transaction. Then the reservation pattern takes two steps: first reserve the key in a separate table with an expiry, then write the event, and finally confirm the reservation; reservations left in between are cleaned up when they expire. Marten running on PostgreSQL takes that complexity off our hands.
Dynamic Consistency Boundary
Uniqueness is a special case. The more general question is: what do you do when a rule looks at the data of two aggregates at the same time? In the back-to-school campaign, two copies each of signed editions of Dune and Emma were set aside for the campaign, and a customer can get at most one signed copy during the campaign. The stock item knows the quota; the customer knows their allowance. Merging both into one aggregate means locking the whole campaign in a single stream; keeping them separate means two transactions and a compensation flow.
Sara Pellegrini proposed another way in a series that began in April 2023 with the post "I am here to kill the aggregate"; the idea later got the name Dynamic Consistency Boundary (DCB). Define the consistency boundary not as an aggregate fixed at design time but as a query that selects the events each decision reads; mark events with tags, and when writing use the condition "reject if an event matching this query has been appended since I read". The specification at dcb.events defines the minimum event store capability needed for this: reading by event type and tag, and conditional append. In the "The Evolution of DDD and Common Mistakes: From the Blue Book to Dynamic Consistency Boundaries" post of the Domain-Driven Design (DDD) series we built this idea with a small in-memory store. Marten has supported DCB since version 8.23, released in March 2026; let's solve the same problem with a real event store this time.
// The campaign rule spans two aggregates: the book's promo quota and the customer's promo allowance
public sealed record PromoCopyClaimed(StockItemId StockItemId, CustomerId CustomerId, OrderId OrderId, DateTimeOffset At);
[BoundaryAggregate]
public sealed class PromoClaims
{
public Dictionary<Guid, int> ByStockItem { get; } = [];
public Dictionary<Guid, int> ByCustomer { get; } = [];
public void Apply(PromoCopyClaimed e)
{
ByStockItem[e.StockItemId.Value] = ByStockItem.GetValueOrDefault(e.StockItemId.Value) + 1;
ByCustomer[e.CustomerId.Value] = ByCustomer.GetValueOrDefault(e.CustomerId.Value) + 1;
}
}
opts.Events.AddEventType<PromoCopyClaimed>();
opts.Events.RegisterTagType<StockItemId>("stock_item").ForAggregate<PromoClaims>();
opts.Events.RegisterTagType<CustomerId>("customer").ForAggregate<PromoClaims>();
PromoClaims does not belong to a stream; it is a boundary aggregate built from the events the tag query selects. In Marten 9, such an aggregate without an Id must be marked with [BoundaryAggregate]. The decision function defines the query, reads the state, and appends the tagged event if needed:
const int PromoQuota = 2;
const int MaxPerCustomer = 1;
async Task<(IDocumentSession Session, string Outcome)> Decide(StockItemId item, CustomerId customer)
{
IDocumentSession session = store.LightweightSession();
EventTagQuery query = new EventTagQuery()
.Or<PromoCopyClaimed, StockItemId>(item)
.Or<PromoCopyClaimed, CustomerId>(customer);
IEventBoundary<PromoClaims> boundary = await session.Events.FetchForWritingByTags<PromoClaims>(query);
PromoClaims claims = boundary.Aggregate ?? new PromoClaims();
if (claims.ByStockItem.GetValueOrDefault(item.Value) >= PromoQuota)
return (session, "rejected: promo quota used up");
if (claims.ByCustomer.GetValueOrDefault(customer.Value) >= MaxPerCustomer)
return (session, "rejected: customer already claimed a promo copy");
IEvent claimed = session.Events.BuildEvent(
new PromoCopyClaimed(item, customer, new OrderId(Guid.CreateVersion7()), at));
claimed.WithTag(item, customer);
boundary.AppendOne(claimed);
return (session, "claimed");
}
async Task Commit(string who, (IDocumentSession Session, string Outcome) decision)
{
await using IDocumentSession session = decision.Session;
await session.SaveChangesAsync();
Console.WriteLine($"{who,-6} {decision.Outcome}");
}
StockItemId signedDune = new(Guid.CreateVersion7()), signedEmma = new(Guid.CreateVersion7());
CustomerId ada = new(Guid.CreateVersion7()), alan = new(Guid.CreateVersion7()),
grace = new(Guid.CreateVersion7()), linus = new(Guid.CreateVersion7());
await Commit("Ada", await Decide(signedDune, ada));
await Commit("Ada", await Decide(signedEmma, ada));
// Alan and Grace decide on the last promo copy of Dune at the same time; Linus on another book
(IDocumentSession, string) alanDecision = await Decide(signedDune, alan);
(IDocumentSession, string) graceDecision = await Decide(signedDune, grace);
(IDocumentSession, string) linusDecision = await Decide(signedEmma, linus);
await Commit("Alan", alanDecision);
try
{
await Commit("Grace", graceDecision);
}
catch (DcbConcurrencyException)
{
Console.WriteLine("Grace conflict: DcbConcurrencyException, deciding again");
await Commit("Grace", await Decide(signedDune, grace));
}
await Commit("Linus", linusDecision);
Ada claimed
Ada rejected: customer already claimed a promo copy
Alan claimed
Grace conflict: DcbConcurrencyException, deciding again
Grace rejected: promo quota used up
Linus claimed
Ada's second attempt ran into her own customer allowance. Alan and Grace decided on Dune's last promo copy at the same time; because Alan's event matched Grace's query (stock_item: Dune), Grace's save was rejected with DcbConcurrencyException, and when she decided again she saw the quota was used up. Linus had decided at the same moments, but his query was limited to Emma and himself; Alan's event did not touch it. The consistency boundary is as large as the decision's query.
Marten performs this check in a side table called mt_dcb_tag_version, with one row per tag value: a conditional UPDATE against the version read at fetch time serializes two concurrent writers at PostgreSQL's default READ COMMITTED isolation. This table was added in 9.4 to fix a bug (#4591) where two truly concurrent saves could both get through; if you use DCB you should be on at least 9.4. Version 9.0 also brought the DcbStorageMode.HStore option, which keeps tags in a single hstore column on mt_events.
The trade-off against an aggregate has to be stated honestly. The gain: a boundary exactly as wide as the rule needs, no giant aggregate, no compensation flow. The cost: every decision has to design its own query correctly, and forgetting to add a tag to an event is not an error; it just drops that event from the query's answer (Marten 9.30 added tag rules defined with TagWith for this). The ecosystem is young too: the specification page carries no version number, Axon Framework 5 supports it on the Java side, and the .NET libraries other than Marten are new. In Bookstore, places with a clear boundary like the stock item stay aggregates; we keep DCB for rules that really span two models, like the campaign quota.
Not Touching the Outside World on Replay
The quiet rule of event sourcing: replay, that is rebuilding state from events, must be free of side effects. If evolve sends an email or calls the carrier's API (Application Programming Interface), every rehydration, every rebuild, and every snapshot refresh repeats that call. The right place is the moment of decision: the handler makes the external call once and writes its result into the event; evolve only reads that result.
public interface ICarrierApi
{
Task<string> CreateLabelAsync(OrderId orderId);
}
public sealed record ShipmentLabelCreated(OrderId OrderId, string TrackingNumber, DateTimeOffset At);
public sealed record Shipment(Guid Id, OrderId OrderId, string TrackingNumber)
{
// Replay only reads the result in the event; it never calls the carrier
public static Shipment Create(JasperFx.Events.IEvent<ShipmentLabelCreated> e) =>
new(e.StreamId, e.Data.OrderId, e.Data.TrackingNumber);
}
public sealed class CreateShipmentLabelHandler(IDocumentStore store, ICarrierApi carrier, TimeProvider clock)
{
public async Task<Guid> HandleAsync(OrderId orderId)
{
// The external call is made once, at decision time, and its result is written to the event
string trackingNumber = await carrier.CreateLabelAsync(orderId);
Guid shipmentId = Guid.CreateVersion7();
await using IDocumentSession session = store.LightweightSession();
session.Events.StartStream<Shipment>(shipmentId, new ShipmentLabelCreated(orderId, trackingNumber, clock.GetUtcNow()));
await session.SaveChangesAsync();
return shipmentId;
}
}
FakeCarrierApi carrier = new();
CreateShipmentLabelHandler labels = new(store, carrier, TimeProvider.System);
Guid shipmentId = await labels.HandleAsync(new OrderId(Guid.CreateVersion7()));
await using IQuerySession query = store.QuerySession();
for (int i = 0; i < 3; i++)
{
Shipment? shipment = await query.Events.AggregateStreamAsync<Shipment>(shipmentId);
Console.WriteLine($"Replay {i + 1}: {shipment!.TrackingNumber}");
}
Console.WriteLine($"Carrier API calls: {carrier.Calls}");
Replay 1: TRK-0001
Replay 2: TRK-0001
Replay 3: TRK-0001
Carrier API calls: 1
Three replays, one API call; the tracking number sits in the event as something that happened. The same principle applies in the other direction: sending something to the outside in response to an event (an email, a message to another service) is not a projection's job but that of a subscription that keeps its own checkpoint, or of an outbox. In post 07 we saw that Marten does not run projection side effects during rebuilds; the outbox and idempotent consumers are the subject of the upcoming Messaging and Event-Driven Architecture series.
Common Pitfalls
"Let's delete the stream when a deletion request arrives"
Deleting the stream takes the personal data away, but with it the stream's business history and the ability to rebuild the read models derived from it. Keep personal data out of events from the start; let a deletion request end with a DELETE.
Forgetting the read models
You managed personal data correctly in the event store, but a PendingNotification read model keeps email addresses in plain text. The deletion process has to cover every read model, cache, log, and backup. Being able to rebuild a read model from scratch is an advantage here: you rebuild it with a new projection that does not contain personal data.
Checking uniqueness against an async read model
Asking an async read model "does this book have a stock item?" produces two records on the day two requests arrive a few milliseconds apart; the naive output above is exactly that. The check must be in the same atomic step as the write.
Moving every cross-stream rule to DCB
DCB is new and attractive, but most rules either fit in a single aggregate or can live with eventual consistency. If it is acceptable for the business to notice "a customer can get one copy in the campaign" a minute late and cancel the order, two aggregates and a compensation step mean fewer new concepts than DCB.
A Small Experiment
★☆☆ Add key rotation to PersonalDataProtector: renew Alan's key and find out what information has to be added to the event so that old events can still be read (hint: a key version).
★★☆ Instead of the unique index, build a two-step reservation pattern that writes the StockItemByBook record in a separate session before the command. What happens to the reservation if the event cannot be written?
★★★ Add the rule "once the campaign quota is used up, a cancelled claim reopens the copy" to the DCB example: a PromoClaimCancelled event, its tags, and PromoClaims.Apply. Run Grace's retry scenario again together with Alan's cancellation.
Over three posts we discussed how event sourcing is done: the event store, projections, versioning, and the hard parts. In the last post of the series we return to the question from the very beginning and turn when CQRS (Command Query Responsibility Segregation) and event sourcing are unnecessary, and which signal justifies which level, into a decision guide.