Skip to content

Fluent API

Tayra provides a fluent configuration API for defining PII protection and blind indexes on entity types. This is an alternative to the attribute-based approach and follows the same pattern as EF Core's OnModelCreating.

When to Use the Fluent API

The fluent API is the right choice when:

  • You cannot modify the model class - The type comes from a third-party library or a shared contract.
  • You prefer centralized configuration - All PII mappings are defined in one place rather than scattered across model files.
  • You want to keep models clean - No Tayra attributes on your domain entities.

For types you own and want to annotate directly, the attribute-based approach remains the simplest option. You can also mix both styles in the same application.

Getting Started

Call Entity<T>() inside AddTayra() to configure entity types. Key store registration chains from the returned TayraBuilder:

cs
fluentApiServices.AddTayra(opts =>
{
    opts.LicenseKey = licenseKey;
    opts.Entity<FluentCustomer>(e =>
    {
        e.DataSubjectId(c => c.CustomerId);
        e.PersonalData(c => c.Name);
        e.PersonalData(c => c.Email)
            .WithReplacement("redacted@example.com");
    });
});
anchor

Call Entity<T>() multiple times to configure several entity types:

cs
multiServices.AddTayra(opts =>
{
    opts.LicenseKey = licenseKey;
    opts.Entity<FluentCustomer>(e =>
    {
        e.DataSubjectId(c => c.CustomerId);
        e.PersonalData(c => c.Name);
    });
    opts.Entity<Employee>(e =>
    {
        e.DataSubjectId(c => c.EmployeeId);
        e.PersonalData(c => c.FullName);
        e.PersonalData(c => c.Ssn)
            .WithReplacement("***-**-****");
    });
});
anchor

Precedence

Fluent configuration takes precedence over attributes. If the same property is configured via both an attribute and the fluent API, the fluent configuration wins. This mirrors the precedence model in EF Core's OnModelCreating.

TIP

You do not need to remove attributes when switching to fluent configuration. Fluent registrations simply override them.

Complete API Reference

EntityTypeBuilder<T>

The EntityTypeBuilder<T> is the entry point for configuring a single entity type. It is provided as a parameter to the Entity<T>() callback.

MethodReturnsPurpose
DataSubjectId(c => c.Id)DataSubjectIdBuilderMarks a property as the data subject identifier
PersonalData(c => c.Email)PersonalDataBuilder<T, string>Marks a string property for encryption
PersonalData(c => c.Emails)PersonalDataBuilder<T, IEnumerable<string>>Binds a string-collection property (IEnumerable<string>) - each element is encrypted individually
PersonalData(c => c.Dob)PersonalDataBuilder<T, TProp>Binds a non-string property (int, DateTime, ...) - configure its companion byte[]? with .StoredIn(...)
DeepPersonalData(c => c.Address)DeepPersonalDataBuilderMarks a property for recursive nested encryption
BlindIndex(c => c.Email)BlindIndexBuilder<T>Configures an HMAC blind index on a string property
CompoundBlindIndex("IndexName")CompoundBlindIndexBuilder<T>Configures a compound blind index over multiple fields
ArrayBlindIndex(c => c.Emails)ArrayBlindIndexBuilder<T>Configures an HMAC blind index on a string-collection property

DataSubjectIdBuilder

Configures the data subject identifier property. Equivalent to [DataSubjectId].

MethodDescription
WithGroup(string group)Sets the group name for multi-subject-id scenarios. Required when an entity has more than one data subject.
WithPrefix(string prefix)Sets a prefix prepended to the subject ID when deriving the encryption key ID.
csharp
e.DataSubjectId(c => c.Id)
    .WithGroup("owner")
    .WithPrefix("cust-");

PersonalDataBuilder<T, TProp>

Configures a [PersonalData] member for AES-256-GCM encryption. The builder is generic over the member type (TProp), so WithReplacement is strongly typed and the masking methods are only available on a string member.

MethodAvailable onDescription
WithGroup(string group)any TPropAssociates the field with an encryption group. Fields in the same group share the same encryption key.
WithReplacement(TProp value)any TPropSets the value written back after the encryption key is deleted (crypto-shredding). Strongly typed to the member - no string parse. Universal and leaks nothing.
StoredIn(c => c.DobEncrypted)non-string TPropSpecifies the companion byte[]? member for a non-string member. Defaults to {MemberName}Encrypted by convention.
WithMaskAfter(int count)string onlyKeeps the first N characters, masks the rest with *.
WithMaskBefore(int count)string onlyKeeps the last N characters, masks the rest with *.
WithMaskEmailDomain()string onlyKeeps the local part of an email, masks the domain.
WithMask(Func<string, string> mask)string onlyAdds a custom inline masking function. See Custom Masking.

The WithMask* methods are extension methods on the closed PersonalDataBuilder<T, string>, so masking a non-string member does not compile at all (masking is a string-only partial-cleartext leak; use WithReplacement for non-string members).

csharp
e.PersonalData(c => c.Email)
    .WithGroup("owner")
    .WithReplacement("[removed]")
    .WithMaskEmailDomain();

Collections

.PersonalData() has a dedicated overload for string-collection properties (Expression<Func<T, IEnumerable<string>>>), so List<string>, string[], HashSet<string>, and other IEnumerable<string> types bind directly:

csharp
e.PersonalData(c => c.EmailAliases).WithGroup("owner");

Each element is encrypted individually. The runtime collection instance must be mutable - read-only collections throw a clear error at encrypt/decrypt time. See Collection Encryption.

DeepPersonalDataBuilder

Marks a property whose type contains its own personal data fields. Tayra recurses into the nested object and encrypts any configured fields. Equivalent to [DeepPersonalData].

MethodDescription
WithScope(string scope)Groups nested objects under a named scope for key isolation. Objects with different scopes use separate encryption keys, allowing independent crypto-shredding.
csharp
e.DeepPersonalData(c => c.Address);
e.DeepPersonalData(c => c.BillingAddress).WithScope("billing");

The nested type (Address in this example) must also be configured, either via attributes or its own Entity<Address>() call.

Collections

.DeepPersonalData() works with collection properties (List<T>, T[], IList<T>) - the collection element type is detected when the metadata is built, and Tayra recurses into each element exactly as the [DeepPersonalData] attribute does. No separate method is needed.

Non-string members (companion byte[]?)

A non-string member (int, DateTime, an enum, ...) uses the same PersonalData(...) method - the builder binds by inference over the member type. The value is JSON-serialized and its ciphertext is stored in a companion byte[]? member, configured with .StoredIn(...) (or left to the {MemberName}Encrypted convention):

csharp
e.PersonalData(c => c.DateOfBirth)
    .WithGroup("owner")
    .StoredIn(c => c.DateOfBirthEncrypted);

Use .WithReplacement(...) here too for a typed shred value, e.g. .WithReplacement(new DateOnly(1900, 1, 1)).

Deprecated: SerializedPersonalData(...)

The old e.SerializedPersonalData(...) fluent method (and the standalone SerializedPersonalDataBuilder<T>) are gone - SerializedPersonalData(...) remains only as an [Obsolete] shim delegating to PersonalData(...). Use PersonalData(...) for every member type.

BlindIndexBuilder<T>

Configures an HMAC blind index on a string property for equality queries on encrypted data. Equivalent to [BlindIndex].

MethodDescription
WithLowercase()Adds the lowercase transform.
WithTrim()Adds the trim transform.
WithAlphanumeric()Adds the alphanumeric transform (strips non-alphanumeric characters).
WithDigits()Adds the digits transform (strips non-digit characters).
WithLast4()Adds the last4 transform (keeps only the last 4 characters).
WithFirstChar()Adds the first_char transform (keeps only the first character).
WithTransform(Func<string, string>)Adds a custom inline transform function.
StoredIn(c => c.EmailIndex)Specifies the companion string? property where the HMAC hash is stored. Defaults to {PropertyName}Index by convention.
WithIndexName(string indexName)Sets a custom index name. Defaults to {PropertyName}Index.
WithScope(string scope)Groups blind indexes under a named HMAC key. All indexes sharing a scope use the same key (stored as bi:{scope} in IKeyStore). Use separate scopes to isolate HMAC keys across contexts - rotating or deleting a scope's key affects only its indexes. Default: "default".
WithBitLength(int bitLength)Truncates the HMAC hash to the specified number of bits. 0 means full 256-bit hash.
csharp
e.BlindIndex(c => c.Email)
    .WithLowercase()
    .WithTrim()
    .StoredIn(c => c.EmailIndex)
    .WithIndexName("EmailHash")
    .WithScope("search")
    .WithBitLength(128);

Custom transforms can be mixed in using WithTransform():

csharp
e.BlindIndex(c => c.Phone)
    .WithTransform(value => new string(value.Where(char.IsDigit).ToArray()))
    .WithLast4();

Blind Indexing Registration

Blind index services are registered automatically by AddTayra() when [BlindIndex] attributes or fluent .BlindIndex() configurations are present. No additional registration call is needed.

ArrayBlindIndexBuilder<T>

Configures an HMAC blind index on a string-collection property (string[], List<string>, IList<string>, IReadOnlyList<string>, or HashSet<string>, including nullable-element variants). The companion is a same-shape collection holding one hash per element. Equivalent to [ArrayBlindIndex]. See Array blind indexes for shape rules and querying.

MethodDescription
WithLowercase()Adds the lowercase transform (applied per element).
WithTrim()Adds the trim transform (applied per element).
WithAlphanumeric()Adds the alphanumeric transform (strips non-alphanumeric characters).
WithDigits()Adds the digits transform (strips non-digit characters).
WithLast4()Adds the last4 transform (keeps only the last 4 characters).
WithFirstChar()Adds the first_char transform (keeps only the first character).
WithTransform(Func<string, string>)Adds a custom inline transform function.
StoredIn(c => c.EmailsIndex)Specifies the companion collection property where the hashes are stored. Must be the same collection kind and element nullability as the source. Defaults to {PropertyName}Index by convention.
WithIndexName(string indexName)Sets a custom index name. Defaults to {PropertyName}Index.
WithScope(string scope)Groups blind indexes under a named HMAC key (stored as bi:{scope} in IKeyStore). Default: "default".
WithBitLength(int bitLength)Truncates each element hash to the specified number of bits. 0 means full 256-bit hash.
csharp
e.ArrayBlindIndex(c => c.Emails)
    .WithLowercase()
    .WithTrim()
    .StoredIn(c => c.EmailsIndex)
    .WithScope("emails")
    .WithBitLength(0);

The fluent and attribute paths produce identical companions for the same plaintext, transforms, and scope. The shape (source and companion collection types) is validated when the metadata is built; the attribute form is additionally checked at compile time by TAYRA010.

CompoundBlindIndexBuilder<T>

Configures a compound blind index that combines multiple fields into a single HMAC hash. Equivalent to [CompoundBlindIndex].

MethodDescription
Field(c => c.FirstName)Adds a field to the compound index. Returns a CompoundFieldBuilder<T> for chaining transforms.
StoredIn(c => c.FullNameIndex)Specifies the companion string? property where the compound hash is stored. Defaults to {IndexName} property by convention.
WithScope(string scope)Groups this compound index under a named HMAC key (stored as bi:{scope} in IKeyStore). Separate scopes isolate HMAC keys - rotating or deleting a scope's key affects only its indexes. Default: "default".
WithBitLength(int bitLength)Truncates the HMAC hash to the specified number of bits. 0 means full 256-bit hash.

CompoundFieldBuilder<T>

Returned by CompoundBlindIndexBuilder<T>.Field(). Provides the same transform methods as BlindIndexBuilder<T>:

MethodDescription
WithLowercase()Adds the lowercase transform.
WithTrim()Adds the trim transform.
WithAlphanumeric()Adds the alphanumeric transform.
WithDigits()Adds the digits transform.
WithLast4()Adds the last4 transform.
WithFirstChar()Adds the first_char transform.
WithTransform(Func<string, string>)Adds a custom inline transform function.
Field(c => c.NextField)Adds the next field to the compound index.
StoredIn(...) / WithScope(...) / WithBitLength(...)Delegates back to the parent CompoundBlindIndexBuilder<T>.
csharp
e.CompoundBlindIndex("FullNameIndex")
    .Field(c => c.FirstName).WithLowercase().WithTrim()
    .Field(c => c.LastName).WithLowercase().WithTrim()
    .StoredIn(c => c.FullNameIndex)
    .WithScope("lookup")
    .WithBitLength(64);

Attribute Comparison

The following table shows the attribute-based syntax alongside its fluent API equivalent.

AttributeFluent API Equivalent
[DataSubjectId]e.DataSubjectId(c => c.Id)
[DataSubjectId(Group = "owner")]e.DataSubjectId(c => c.Id).WithGroup("owner")
[DataSubjectId(Prefix = "cust-")]e.DataSubjectId(c => c.Id).WithPrefix("cust-")
[PersonalData]e.PersonalData(c => c.Name)
[PersonalData(ReplacementValue = "[removed]")]e.PersonalData(c => c.Name).WithReplacement("[removed]")
[PersonalData(Masking = MaskingStrategies.MaskEmailDomain)]e.PersonalData(c => c.Email).WithMaskEmailDomain()
[PersonalData(Masking = MaskingStrategies.MaskAfter, MaskingParameter = 2)]e.PersonalData(c => c.Name).WithMaskAfter(2)
[DeepPersonalData]e.DeepPersonalData(c => c.Address)
[PersonalData] on DateOfBirth + byte[]? DateOfBirthEncryptede.PersonalData(c => c.DateOfBirth).StoredIn(c => c.DateOfBirthEncrypted)
[BlindIndex(Transforms = ["lowercase"])]e.BlindIndex(c => c.Email).WithLowercase()
[CompoundBlindIndex("FullNameIndex", ...)]e.CompoundBlindIndex("FullNameIndex").Field(c => c.FirstName).WithLowercase()
[ArrayBlindIndex(Transforms = ["lowercase"])]e.ArrayBlindIndex(c => c.Emails).WithLowercase()

Mixed Mode

You can use attributes on some types and fluent configuration on others within the same application. This is useful when you own some models but need to configure third-party types without modifying them.

cs
// Customer uses attributes defined on the class itself
// [DataSubjectId] on Id, [PersonalData] on Name, etc.

// ExternalContact comes from a shared library - use fluent config
var mixedServices = new ServiceCollection();
mixedServices.AddTayra(opts =>
{
    opts.LicenseKey = licenseKey;
    opts.Entity<ExternalContact>(e =>
    {
        e.DataSubjectId(c => c.ContactId);
        e.PersonalData(c => c.FullName);
        e.PersonalData(c => c.PhoneNumber)
            .WithReplacement("[redacted]");
    });
});
anchor

Both attribute-discovered metadata and fluent-registered metadata are stored in the same PersonalDataMetadataCache. At runtime, encryption and decryption work identically regardless of how the metadata was defined.

See Also