Skip to content

Marten

Tayra integrates with Marten by wrapping the configured serializer with TayraSerializer. This provides transparent encryption and decryption of [PersonalData] fields in both documents and events stored in PostgreSQL's JSONB columns.

Prerequisites

Tayra ships a package per Marten major (pick by your Marten version), both requiring PostgreSQL 13+:

  • Tayra.Marten9 - Marten 9.x, .NET 9+.
  • Tayra.Marten8 - Marten 8.x, .NET 8+.

The framework major is in the package name; the version number is Tayra's own. See Installation.

Install

Install the package that matches your Marten major:

shell
dotnet add package Tayra.Marten9
shell
dotnet add package Tayra.Marten8

Either package exposes the same Tayra.Marten namespace and API, so the code below is identical regardless of which you install.

Setup

Call UseTayra() on Marten's StoreOptions to enable PII encryption. This wraps Marten's default serializer with TayraSerializer:

cs
// Configure Marten with Tayra encryption.
// UseTayra() wraps the serializer so PII fields are encrypted in JSONB storage.
var connectionString = "Host=localhost;Port=5432;Database=tayra_sample;Username=postgres;Password=postgres";

services.AddMarten(opts =>
{
    opts.Connection(connectionString);

    // Enable Tayra encryption for documents and events
    opts.UseTayra(tayra);
});
anchor

How TayraSerializer Works

TayraSerializer is a decorator around Marten's existing ISerializer. When serializing (e.g., ToJson()), it encrypts [PersonalData] fields before passing the object to the inner serializer. When deserializing (e.g., FromJson<T>()), it decrypts the fields after the inner serializer reconstructs the object. All non-PII fields pass through untouched.

Custom serializers

Because TayraSerializer is a decorator, a custom serializer is fully supported - Tayra wraps yours and delegates all serialization (naming policy, converters, enum storage, casing) to it, adding only the encrypt/decrypt of [PersonalData]. This works for opts.Serializer(new MyCustom()), opts.UseSystemTextJsonForSerialization(...), opts.UseNewtonsoftForSerialization(...), or any third-party ISerializer.

The only rule is that Tayra must wrap last:

  • DI registration (services.UseTayra()) - recommended. Tayra runs after your entire AddMarten(opts => …) lambda, so it wraps whatever serializer you configured, wherever you set it.
  • opts.UseTayra(tayra) inside the lambda. This wraps the serializer set at that moment. Calling opts.Serializer(...) (or UseSystemTextJsonForSerialization, etc.) after opts.UseTayra(tayra) overwrites the wrapper - call UseTayra last.

Encryption-off is a startup failure, not a silent one

If a custom serializer (or another library's IConfigureMarten registered after Tayra) replaces the wrapper so the store's final serializer is not a TayraSerializer, the DI registration's startup guard throws at host startup rather than letting PII write in cleartext. If you hit this, ensure UseTayra() is applied last.

How It Works

Tayra wraps Marten's ISerializer to transparently encrypt and decrypt PII fields during serialization. Encryption is selective by type - only entities with [PersonalData] annotations are encrypted. Types without PII attributes pass through unmodified.

Documents

Annotate your Marten document classes with [DataSubjectId] and [PersonalData]:

cs
public class CustomerDocument
{
    public Guid Id { get; set; }

    [DataSubjectId]
    public string SubjectId { get; set; } = "";

    [PersonalData]
    public string Name { get; set; } = "";

    [PersonalData(MaskValue = "redacted@example.com")]
    public string Email { get; set; } = "";

    /// <summary>
    /// Not annotated - stored as plaintext in JSONB.
    /// </summary>
    public string AccountType { get; set; } = "";
}
anchor
  • [DataSubjectId] on SubjectId identifies the data owner.
  • [PersonalData] on Name and Email marks them for encryption.
  • AccountType is not annotated and stored as plaintext in JSONB.

When you store this document with session.Store(customer), the Name and Email values in the JSONB column will contain AES-256-GCM ciphertext. When you load it with session.Load<CustomerDocument>(id), the fields are decrypted back to plaintext automatically.

Events

Event payloads are encrypted the same way as documents:

cs
public class CustomerRegisteredEvent
{
    [DataSubjectId]
    public string SubjectId { get; set; } = "";

    [PersonalData]
    public string CustomerName { get; set; } = "";

    [PersonalData(MaskValue = "redacted@example.com")]
    public string CustomerEmail { get; set; } = "";

    public DateTime RegisteredAt { get; set; }
}
anchor

When you append this event to a stream, the CustomerName and CustomerEmail values are encrypted in the event store. Events are immutable in Marten, so the encrypted values are permanent - which is exactly what makes crypto-shredding work.

Event Sourcing and GDPR

Crypto-shredding is the recommended approach for GDPR compliance in event-sourced systems. Instead of rewriting event history (which violates event sourcing principles), you delete the encryption key. The events remain intact, but the PII fields become permanently unreadable.

Crypto-Shredding

Delete a data subject's encryption keys to make all their PII permanently unreadable:

cs
// GDPR "right to be forgotten" - destroy encryption keys for a data subject.
// After this, any attempt to decrypt the subject's data returns replacement values.
//
// Usage with Marten IDocumentSession:
//   await session.ShredDataSubjectAsync(cryptoEngine, subjectId);
//   await session.SaveChangesAsync();

// Example (standalone, without a live Marten session):
var subjectId = "cust-42";
await cryptoEngine.DeleteAllKeysAsync(subjectId);
Console.WriteLine($"Crypto-shredded all keys for subject '{subjectId}'.");
anchor

The ShredDataSubjectAsync extension method on IDocumentSession calls cryptoEngine.DeleteAllKeysAsync() to destroy all encryption keys for the specified subject.

Check if Shredded

Verify whether a data subject has been crypto-shredded:

cs
// Check if a data subject's keys have been shredded.
//
// Usage with Marten IDocumentSession:
//   bool shredded = await session.IsDataSubjectShreddedAsync(cryptoEngine, subjectId);
//
// Standalone equivalent:
var keyExists = await cryptoEngine.KeyExistsAsync(subjectId);
var isShredded = !keyExists;
Console.WriteLine($"Subject '{subjectId}' is shredded: {isShredded}");
anchor

The IsDataSubjectShreddedAsync extension checks whether the subject's encryption key still exists in the key store. If the key is gone, the subject has been shredded.

Projections

Projections work with no extra configuration. Once UseTayra() is registered, every event is deserialized through TayraSerializer, so [PersonalData] fields are already decrypted by the time they reach your projection. Register projections exactly as you would in plain Marten:

cs
// Projections need no Tayra-specific wiring. Because UseTayra() wraps the
// serializer, every event is decrypted on read before it reaches your
// projection — inline, live aggregation, and the async daemon alike.
// Register projections exactly as in plain Marten:
services.AddMarten(opts =>
{
    opts.Connection(connectionString);
    opts.UseTayra(tayra);

    // Standard Marten registration — events arrive decrypted in Apply/ApplyAsync,
    // and any [PersonalData] on the projected document is re-encrypted at rest.
    // opts.Projections.Add(myProjection, ProjectionLifecycle.Inline);
});

Console.WriteLine("\nProjections require no special API: the serializer decrypts events on every read path.");
Console.WriteLine("[PersonalData] on projected read models is re-encrypted at rest automatically.");
anchor

Why No Special API Is Needed

TayraSerializer is the single seam through which Marten deserializes event data (ISerializer.FromJson). Marten uses that same seam for inline projections, live aggregations (AggregateStreamAsync, FetchForWriting), and the async projection daemon alike. Your Apply/ApplyAsync handlers therefore receive cleartext values and never need to know about encryption.

If a projection writes a document with its own [PersonalData] fields (a read model), those fields are re-encrypted at rest automatically, because the projected document is written back through TayraSerializer as JSONB.

Binary events Tayra.Marten9

Marten 9 can serialize events to mt_events.bdata via a separate IEventBinarySerializer ([BinaryEvent], opts.Events.UseBinarySerializer<T>(), or a store-wide opts.Events.DefaultBinarySerializer). That path bypasses the document serializer, so Tayra protects it with a binary-format counterpart, TayraBinaryEventSerializer:

  • Store-wide default - set opts.Events.DefaultBinarySerializer before UseTayra() and Tayra wraps it automatically; every [BinaryEvent] is then protected.
  • Per event type - wrap explicitly: opts.Events.UseBinarySerializer<MyEvent>(new TayraBinaryEventSerializer(inner, tayra)).

PII fields are encrypted inside the bdata blob exactly as they are in JSONB, and crypto-shredding works unchanged.

Binary PII is guarded, not silent

If an event type carries [PersonalData] and uses a binary serializer that is not a TayraBinaryEventSerializer, UseTayra() throws at startup rather than writing cleartext to mt_events.bdata. The TAYRA011 analyzer also flags [PersonalData] on a [BinaryEvent] type at compile time. To opt a binary event out of encryption, remove the [PersonalData] annotation.

Flat-table projections

FlatTableProjection writes event member values straight into SQL columns via a generated upsert function. That path never passes through TayraSerializer, and StatementMap<T>.Map(...) only accepts a member expression (x => x.Email), so there is no seam to encrypt a flat-table column - and encrypting one would defeat its purpose, since flat tables exist to be queried in SQL.

There is no such thing as a flat column that is both the readable PII value and protected at rest. The choices are mutually exclusive:

Column holdsReadable value?SQL-queryable?Protected at rest?
The PII member (x.Email)yesyesno - cleartext
A blind-index companion (x.EmailIndex)no (one-way HMAC)equality onlyyes
A Tayra-encrypted blobno (until app-side decrypt)noyes

So the strategy is to keep personal data out of flat tables entirely:

  • The flat table carries non-PII columns plus blind-index (HMAC) companions for equality search. Map x => x.EmailIndex, never x => x.Email. The hash gives you search without the value - it is not a protected copy of the email.
  • The actual PII value stays in the encrypted JSONB document projection. Look it up by id (optionally found via the flat table's blind-index column) when you need the value.

Flat-table PII is not protected

Mapping a [PersonalData] member into a flat-table column stores it in cleartext, and Tayra cannot prevent this at runtime - there is no encryption seam for flat tables. The TAYRA007 analyzer flags this at compile time. Map a blind-index companion instead, or keep the data in the encrypted JSONB projection.

Duplicated fields

Marten's duplicated fields copy a document member into its own indexed relational column. Marten populates that column by reading the value off your .NET object, outside TayraSerializer, so:

  • Never duplicate a [PersonalData] member. Because Tayra encrypts the field in place during serialization, the duplicated column ends up holding non-deterministic ciphertext - unqueryable (the whole point of duplication) and not decryptable on read. The TAYRA008 analyzer flags [DuplicateField] and Duplicate(x => x.PiiMember) on a personal-data member.
  • Duplicate the blind-index companion instead. The companion (EmailIndex) is a deterministic HMAC that Tayra writes onto your object before Marten reads it, so the duplicated column is correctly populated and fully queryable:
csharp
public class Customer
{
    [DataSubjectId] public string Id { get; set; }
    [PersonalData, BlindIndex] public string Email { get; set; }
    public string EmailIndex { get; set; } = "";   // companion, Tayra fills it
}

opts.Schema.For<Customer>().Duplicate(x => x.EmailIndex);   // ✅ indexed, queryable, no plaintext

A Where(x => x.EmailIndex == hash) then hits the indexed column while the email stays encrypted in the JSONB.

Index vs Duplicate

If you only need query speed (not a materialized relational column for joins/external SQL), you don't need a duplicated field at all - the HMAC is already in the JSONB, so opts.Schema.For<Customer>().Index(x => x.EmailIndex) indexes data->>'EmailIndex' directly.

Shredded Events in Projections

If a data subject has been crypto-shredded, their events will contain replacement values instead of the original data. Your projections should handle these gracefully - for example, by checking for known replacement values or empty strings.

Blind Indexes

Tayra's blind index support works transparently with Marten. Blind index services are registered automatically by AddTayra() when [BlindIndex] attributes are present, so TayraSerializer automatically computes HMAC blind indexes on companion properties before encrypting PII fields during serialization.

Define a model with [BlindIndex] on encrypted fields:

csharp
public class CustomerDocument
{
    public Guid Id { get; set; }

    [DataSubjectId]
    public string SubjectId { get; set; } = "";

    [PersonalData, BlindIndex(Transforms = ["lowercase", "trim"])]
    public string Email { get; set; } = "";

    public string? EmailIndex { get; set; }  // auto-populated
}

Query by blind index using LINQ:

csharp
var hash = await blindIndexer.ComputeHashAsync(
    "alice@example.com", "EmailIndex", typeof(CustomerDocument));
var customer = await session.Query<CustomerDocument>()
    .Where(c => c.EmailIndex == hash)
    .FirstOrDefaultAsync();

For efficient queries, add a Marten computed index on the companion property:

csharp
opts.Schema.For<CustomerDocument>().Index(x => x.EmailIndex);

See Blind Indexes for the full guide including transforms, compound indexes, and security considerations.

Data Migration

If you're adding Tayra to an existing application with pre-existing cleartext documents, use the Marten migration service to bulk-encrypt them. See the Brownfield Adoption guide for step-by-step instructions.

Register the migration service and set up a raw store:

cs
// Register Tayra Marten migration services (requires AddTayra() first)
var migrationServices = new ServiceCollection();
migrationServices.AddTayra(opts => opts.LicenseKey = licenseKey);
migrationServices.AddTayraMartenMigrations();

var migrationProvider = migrationServices.BuildServiceProvider();
anchor
cs
// Create a "raw" store WITHOUT UseTayra() - reads cleartext field values as-is.
// This is required so the migration service can detect which documents need encrypting.
var rawStore = DocumentStore.For(opts =>
{
    opts.Connection(connectionString);
    // Do NOT call opts.UseTayra() here - we need raw access to read cleartext
});
anchor

Then encrypt existing documents and verify:

cs
// Bulk-encrypt existing cleartext documents in batches
var migrationService = migrationProvider.GetRequiredService<ITayraMartenMigrationService>();
var result = await migrationService.EncryptExistingDocumentsAsync<CustomerDocument>(
    rawStore,
    batchSize: 100);

Console.WriteLine($"\n=== Marten Migration Result ===");
Console.WriteLine($"  Scanned:   {result.TotalScanned}");
Console.WriteLine($"  Encrypted: {result.Encrypted}");
Console.WriteLine($"  Skipped:   {result.Skipped}");
Console.WriteLine($"  Errors:    {result.Errors}");
Console.WriteLine($"  Duration:  {result.Duration.TotalMilliseconds:F0}ms");
anchor
cs
// Verify all documents are now properly encrypted
var verification = await migrationService.VerifyDocumentEncryptionAsync<CustomerDocument>(
    rawStore,
    batchSize: 100);

Console.WriteLine($"\n=== Marten Verification Result ===");
Console.WriteLine($"  Verified: {verification.TotalVerified}");
Console.WriteLine($"  Valid:    {verification.Valid}");
Console.WriteLine($"  Invalid:  {verification.Invalid}");
Console.WriteLine($"  Duration: {verification.Duration.TotalMilliseconds:F0}ms");

foreach (var invalid in verification.InvalidRows)
{
    Console.WriteLine($"  [INVALID] Document {invalid.EntityId}, Property: {invalid.PropertyName} - {invalid.Reason}");
}
anchor

See Also