Skip to main content

GDPR + Data Privacy (the hardest problem in ES)

Event sourcing's greatest strength—immutable history—is also its biggest GDPR challenge.

"Right to erasure" (Article 17) says users can request deletion of their personal data. But events are supposed to be immutable.

This isn't impossible to solve, but it requires intentional design from day one.

The core tension

Event Sourcing says: Events are facts. Facts don't change. Never delete.

GDPR says: Users can request deletion of personal data. You must comply.

Idea: Encrypt personal data with per-user keys. To "delete" data, delete the key.

The encrypted data remains, but it's unreadable garbage without the key.

Implementation

// Key management
public interface IEncryptionKeyStore
{
Task<byte[]> GetOrCreateKey(Guid subjectId, CancellationToken ct);
Task<byte[]?> GetKey(Guid subjectId, CancellationToken ct);
Task DeleteKey(Guid subjectId, CancellationToken ct);
Task<bool> KeyExists(Guid subjectId, CancellationToken ct);
}

public sealed class EncryptionKeyStore : IEncryptionKeyStore
{
private readonly IDbConnection _db;

public async Task<byte[]> GetOrCreateKey(Guid subjectId, CancellationToken ct)
{
var existing = await GetKey(subjectId, ct);
if (existing is not null)
return existing;

// Generate new 256-bit key
var key = new byte[32];
RandomNumberGenerator.Fill(key);

await _db.ExecuteAsync(
@"INSERT INTO encryption_keys (subject_id, key_material, created_at)
VALUES (@SubjectId, @KeyMaterial, @CreatedAt)",
new { SubjectId = subjectId, KeyMaterial = key, CreatedAt = DateTimeOffset.UtcNow });

return key;
}

public async Task<byte[]?> GetKey(Guid subjectId, CancellationToken ct)
{
return await _db.QuerySingleOrDefaultAsync<byte[]>(
"SELECT key_material FROM encryption_keys WHERE subject_id = @SubjectId",
new { SubjectId = subjectId });
}

public async Task DeleteKey(Guid subjectId, CancellationToken ct)
{
// This is the "crypto-shredding" - delete the key, data becomes unreadable
await _db.ExecuteAsync(
"DELETE FROM encryption_keys WHERE subject_id = @SubjectId",
new { SubjectId = subjectId });
}

public async Task<bool> KeyExists(Guid subjectId, CancellationToken ct)
{
return await _db.QuerySingleAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM encryption_keys WHERE subject_id = @SubjectId)",
new { SubjectId = subjectId });
}
}

Encryption service

public interface IFieldEncryption
{
Task<string> Encrypt(Guid subjectId, string plaintext, CancellationToken ct);
Task<string?> Decrypt(Guid subjectId, string ciphertext, CancellationToken ct);
}

public sealed class AesFieldEncryption : IFieldEncryption
{
private readonly IEncryptionKeyStore _keyStore;

public AesFieldEncryption(IEncryptionKeyStore keyStore)
{
_keyStore = keyStore;
}

public async Task<string> Encrypt(Guid subjectId, string plaintext, CancellationToken ct)
{
var key = await _keyStore.GetOrCreateKey(subjectId, ct);

using var aes = Aes.Create();
aes.Key = key;
aes.GenerateIV();

using var encryptor = aes.CreateEncryptor();
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
var ciphertextBytes = encryptor.TransformFinalBlock(plaintextBytes, 0, plaintextBytes.Length);

// Prepend IV to ciphertext
var result = new byte[aes.IV.Length + ciphertextBytes.Length];
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
Buffer.BlockCopy(ciphertextBytes, 0, result, aes.IV.Length, ciphertextBytes.Length);

return Convert.ToBase64String(result);
}

public async Task<string?> Decrypt(Guid subjectId, string ciphertext, CancellationToken ct)
{
var key = await _keyStore.GetKey(subjectId, ct);
if (key is null)
return null; // Key was deleted - data is "forgotten"

var data = Convert.FromBase64String(ciphertext);

using var aes = Aes.Create();
aes.Key = key;

// Extract IV from beginning
var iv = new byte[16];
Buffer.BlockCopy(data, 0, iv, 0, 16);
aes.IV = iv;

var ciphertextBytes = new byte[data.Length - 16];
Buffer.BlockCopy(data, 16, ciphertextBytes, 0, ciphertextBytes.Length);

using var decryptor = aes.CreateDecryptor();
var plaintextBytes = decryptor.TransformFinalBlock(ciphertextBytes, 0, ciphertextBytes.Length);

return Encoding.UTF8.GetString(plaintextBytes);
}
}

Events with encrypted PII

// Event with encrypted personal data
public sealed record CustomerRegistered(
Guid CustomerId,
string EncryptedName, // Encrypted with customer's key
string EncryptedEmail, // Encrypted with customer's key
string EncryptedPhone, // Encrypted with customer's key
Guid EncryptionKeyId // Reference to the key (same as CustomerId typically)
) : IDomainEvent;

// Non-PII data stays plaintext
public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId, // Just a reference, not PII itself
decimal TotalAmount,
DateTimeOffset PlacedAt
) : IDomainEvent;

// Factory for creating events with encryption
public sealed class CustomerEventFactory
{
private readonly IFieldEncryption _encryption;

public CustomerEventFactory(IFieldEncryption encryption)
{
_encryption = encryption;
}

public async Task<CustomerRegistered> CreateCustomerRegistered(
Guid customerId,
string name,
string email,
string phone,
CancellationToken ct)
{
return new CustomerRegistered(
CustomerId: customerId,
EncryptedName: await _encryption.Encrypt(customerId, name, ct),
EncryptedEmail: await _encryption.Encrypt(customerId, email, ct),
EncryptedPhone: await _encryption.Encrypt(customerId, phone, ct),
EncryptionKeyId: customerId
);
}
}

Projections with decryption

public sealed record CustomerReadModel(
Guid CustomerId,
string? Name, // null if key was deleted
string? Email, // null if key was deleted
bool IsDataAvailable // false if crypto-shredded
);

public sealed class CustomerProjector
{
private readonly IFieldEncryption _encryption;
private readonly Dictionary<Guid, CustomerReadModel> _customers = new();

public CustomerProjector(IFieldEncryption encryption)
{
_encryption = encryption;
}

public async Task Apply(IDomainEvent @event, CancellationToken ct)
{
if (@event is CustomerRegistered e)
{
var name = await _encryption.Decrypt(e.EncryptionKeyId, e.EncryptedName, ct);
var email = await _encryption.Decrypt(e.EncryptionKeyId, e.EncryptedEmail, ct);

_customers[e.CustomerId] = new CustomerReadModel(
CustomerId: e.CustomerId,
Name: name,
Email: email,
IsDataAvailable: name is not null
);
}
}

public CustomerReadModel? Get(Guid customerId) =>
_customers.TryGetValue(customerId, out var c) ? c : null;
}

GDPR deletion request handler

public sealed class GdprDeletionHandler
{
private readonly IEncryptionKeyStore _keyStore;
private readonly IReadModelStore _readModels;
private readonly ILogger<GdprDeletionHandler> _logger;

public GdprDeletionHandler(
IEncryptionKeyStore keyStore,
IReadModelStore readModels,
ILogger<GdprDeletionHandler> logger)
{
_keyStore = keyStore;
_readModels = readModels;
_logger = logger;
}

public async Task HandleDeletionRequest(Guid customerId, CancellationToken ct)
{
_logger.LogInformation("Processing GDPR deletion for customer {CustomerId}", customerId);

// Step 1: Delete encryption key (crypto-shredding)
await _keyStore.DeleteKey(customerId, ct);

// Step 2: Clear read models (they contain decrypted data)
await _readModels.DeleteCustomerData(customerId, ct);

// Step 3: Record the deletion (for audit)
await RecordDeletionAudit(customerId, ct);

_logger.LogInformation("GDPR deletion completed for customer {CustomerId}", customerId);
}

private async Task RecordDeletionAudit(Guid customerId, CancellationToken ct)
{
// Keep an audit record that deletion occurred (without PII)
// This is allowed/required for compliance demonstration
}
}

Strategy 2: Reference indirection

Idea: Events contain only IDs. Personal data lives in a separate, deletable store.

// Event contains only reference
public sealed record CustomerRegistered(
Guid CustomerId,
Guid PersonalDataRef // Points to deletable store
) : IDomainEvent;

// Personal data in separate store
public sealed record PersonalData(
Guid RefId,
string Name,
string Email,
string Phone
);

public interface IPersonalDataStore
{
Task<Guid> Store(PersonalData data, CancellationToken ct);
Task<PersonalData?> Get(Guid refId, CancellationToken ct);
Task Delete(Guid refId, CancellationToken ct);
}

Pros and cons vs crypto-shredding

AspectCrypto-shreddingReference indirection
Event completenessEvents contain (encrypted) dataEvents contain only refs
Rebuild complexityNeed keys during rebuildNeed external store during rebuild
Key managementRequiredNot required
Data localityData in eventsData split across stores
Deletion certaintyHigh (key destruction)Medium (must delete from store)

Strategy 3: Tombstone events

Idea: Append a "forgotten" event that marks data as deleted.

public sealed record CustomerForgotten(
Guid CustomerId,
DateTimeOffset ForgottenAt,
string Reason // "GDPR Article 17 request"
) : IDomainEvent;

// Projection respects tombstones
public sealed class CustomerProjectorWithTombstones
{
private readonly Dictionary<Guid, CustomerReadModel> _customers = new();
private readonly HashSet<Guid> _forgotten = new();

public void Apply(IDomainEvent @event)
{
switch (@event)
{
case CustomerRegistered e when !_forgotten.Contains(e.CustomerId):
_customers[e.CustomerId] = new CustomerReadModel(
e.CustomerId, e.Name, e.Email, true);
break;

case CustomerForgotten e:
_forgotten.Add(e.CustomerId);
_customers.Remove(e.CustomerId);
break;
}
}

public CustomerReadModel? Get(Guid customerId)
{
if (_forgotten.Contains(customerId))
return new CustomerReadModel(customerId, null, null, false);

return _customers.TryGetValue(customerId, out var c) ? c : null;
}
}

Warning: This doesn't actually delete the PII from the event store. It just marks it as "should be ignored". May not satisfy strict GDPR interpretations.

Handling PII in different event types

Classify your events

// Attribute to mark PII-containing events
[AttributeUsage(AttributeTargets.Class)]
public sealed class ContainsPiiAttribute : Attribute
{
public string[] PiiFields { get; }
public ContainsPiiAttribute(params string[] piiFields) => PiiFields = piiFields;
}

[ContainsPii(nameof(Name), nameof(Email))]
public sealed record CustomerRegistered(
Guid CustomerId,
string Name,
string Email
) : IDomainEvent;

// No PII - no special handling needed
public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId,
decimal Amount
) : IDomainEvent;

Automatic PII detection

public static class PiiDetector
{
public static bool ContainsPii(Type eventType)
{
return eventType.GetCustomAttribute<ContainsPiiAttribute>() is not null;
}

public static string[] GetPiiFields(Type eventType)
{
return eventType.GetCustomAttribute<ContainsPiiAttribute>()?.PiiFields
?? Array.Empty<string>();
}
}

Retention policies

GDPR also requires data minimization. Don't keep data longer than necessary.

public sealed class RetentionPolicy
{
public TimeSpan DefaultRetention { get; init; } = TimeSpan.FromDays(365 * 7); // 7 years
public TimeSpan FinancialDataRetention { get; init; } = TimeSpan.FromDays(365 * 10); // 10 years
public TimeSpan MarketingDataRetention { get; init; } = TimeSpan.FromDays(365 * 2); // 2 years
}

public sealed class RetentionEnforcer
{
private readonly IEncryptionKeyStore _keyStore;
private readonly RetentionPolicy _policy;
private readonly ILogger<RetentionEnforcer> _logger;

public async Task EnforceRetention(CancellationToken ct)
{
var cutoff = DateTimeOffset.UtcNow - _policy.DefaultRetention;

// Find keys for inactive customers past retention
var expiredKeys = await GetExpiredKeys(cutoff, ct);

foreach (var keyId in expiredKeys)
{
_logger.LogInformation("Auto-deleting key {KeyId} due to retention policy", keyId);
await _keyStore.DeleteKey(keyId, ct);
}
}

private Task<IEnumerable<Guid>> GetExpiredKeys(DateTimeOffset cutoff, CancellationToken ct)
{
// Query for keys where last activity was before cutoff
// Implementation depends on your tracking
throw new NotImplementedException();
}
}

Track what data processing the user consented to:

public sealed record ConsentGiven(
Guid CustomerId,
string Purpose, // "marketing", "analytics", "essential"
DateTimeOffset GivenAt,
string ConsentVersion // Version of privacy policy
) : IDomainEvent;

public sealed record ConsentWithdrawn(
Guid CustomerId,
string Purpose,
DateTimeOffset WithdrawnAt
) : IDomainEvent;

public sealed class ConsentProjector
{
private readonly Dictionary<(Guid, string), bool> _consents = new();

public void Apply(IDomainEvent @event)
{
switch (@event)
{
case ConsentGiven e:
_consents[(e.CustomerId, e.Purpose)] = true;
break;
case ConsentWithdrawn e:
_consents[(e.CustomerId, e.Purpose)] = false;
break;
}
}

public bool HasConsent(Guid customerId, string purpose)
{
return _consents.TryGetValue((customerId, purpose), out var consent) && consent;
}
}

Data export (Right to portability)

GDPR Article 20 requires you to export user data in a portable format:

public sealed class DataExportService
{
private readonly IEventStore _eventStore;
private readonly IFieldEncryption _encryption;

public async Task<CustomerDataExport> ExportCustomerData(
Guid customerId,
CancellationToken ct)
{
var export = new CustomerDataExport
{
CustomerId = customerId,
ExportedAt = DateTimeOffset.UtcNow,
Events = new List<ExportedEvent>()
};

// Find all streams related to this customer
var streams = await FindCustomerStreams(customerId, ct);

foreach (var streamId in streams)
{
await foreach (var se in _eventStore.ReadStream(streamId, 0, ct))
{
var decrypted = await DecryptIfNeeded(se, customerId, ct);
export.Events.Add(new ExportedEvent(
StreamId: streamId,
EventType: se.EventType,
OccurredAt: se.OccurredAt,
Data: decrypted
));
}
}

return export;
}

private async Task<string> DecryptIfNeeded(
StoredEvent se,
Guid customerId,
CancellationToken ct)
{
// Decrypt PII fields for export
// Return as JSON
throw new NotImplementedException();
}

private Task<IEnumerable<string>> FindCustomerStreams(Guid customerId, CancellationToken ct)
{
// Find streams: customer-{id}, orders for customer, etc.
throw new NotImplementedException();
}
}

public sealed class CustomerDataExport
{
public Guid CustomerId { get; init; }
public DateTimeOffset ExportedAt { get; init; }
public List<ExportedEvent> Events { get; init; } = new();
}

public sealed record ExportedEvent(
string StreamId,
string EventType,
DateTimeOffset OccurredAt,
string Data
);

Testing GDPR compliance

public sealed class GdprComplianceTests
{
[Fact]
public async Task Crypto_shredding_makes_data_unreadable()
{
var keyStore = new InMemoryEncryptionKeyStore();
var encryption = new AesFieldEncryption(keyStore);
var customerId = Guid.NewGuid();

// Encrypt some data
var encrypted = await encryption.Encrypt(customerId, "John Doe", CancellationToken.None);

// Verify it's readable
var decrypted = await encryption.Decrypt(customerId, encrypted, CancellationToken.None);
Assert.Equal("John Doe", decrypted);

// Delete the key
await keyStore.DeleteKey(customerId, CancellationToken.None);

// Verify data is now unreadable
var afterDeletion = await encryption.Decrypt(customerId, encrypted, CancellationToken.None);
Assert.Null(afterDeletion);
}

[Fact]
public async Task Projection_handles_forgotten_customers()
{
var projector = new CustomerProjectorWithTombstones();

// Register customer
projector.Apply(new CustomerRegistered(
Guid.Parse("11111111-1111-1111-1111-111111111111"),
"John Doe",
"john@example.com"));

var before = projector.Get(Guid.Parse("11111111-1111-1111-1111-111111111111"));
Assert.NotNull(before);
Assert.Equal("John Doe", before.Name);

// Forget customer
projector.Apply(new CustomerForgotten(
Guid.Parse("11111111-1111-1111-1111-111111111111"),
DateTimeOffset.UtcNow,
"GDPR request"));

var after = projector.Get(Guid.Parse("11111111-1111-1111-1111-111111111111"));
Assert.NotNull(after);
Assert.Null(after.Name);
Assert.False(after.IsDataAvailable);
}
}

Checklist for GDPR-compliant event sourcing

  • Identify all PII in your events
  • Choose a deletion strategy (crypto-shredding recommended)
  • Implement encryption key management
  • Ensure projections handle "forgotten" data gracefully
  • Implement data export capability
  • Track consent per purpose
  • Define and enforce retention policies
  • Document your approach for auditors
  • Test deletion and export flows
  • Consider snapshots (they also contain data - encrypt or regenerate)

Next

Performance and scaling become critical as your event store grows.

Next: Performance and scaling

Sources

  • https://gdpr.eu/article-17-right-to-be-forgotten/
  • https://gdpr.eu/article-20-right-to-data-portability/
  • https://www.eventstore.com/blog/gdpr-and-event-sourcing
  • https://www.michielrook.nl/2017/11/event-sourcing-gdpr-follow-up/