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:
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)
.WithMaskValue("redacted@example.com");
});
});Call Entity<T>() multiple times to configure several entity types:
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)
.WithMaskValue("***-**-****");
});
});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.
| Method | Returns | Purpose |
|---|---|---|
DataSubjectId(c => c.Id) | DataSubjectIdBuilder | Marks a property as the data subject identifier |
PersonalData(c => c.Email) | PersonalDataBuilder | Marks a string property for encryption |
DeepPersonalData(c => c.Address) | DeepPersonalDataBuilder | Marks a property for recursive nested encryption |
SerializedPersonalData(c => c.Dob) | SerializedPersonalDataBuilder<T> | Marks a non-string property for serialized 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 |
DataSubjectIdBuilder
Configures the data subject identifier property. Equivalent to [DataSubjectId].
| Method | Description |
|---|---|
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. |
e.DataSubjectId(c => c.Id)
.WithGroup("owner")
.WithPrefix("cust-");PersonalDataBuilder
Configures a string property for AES-256-GCM encryption. Equivalent to [PersonalData].
| Method | Description |
|---|---|
WithGroup(string group) | Associates the field with an encryption group. Fields in the same group share the same encryption key. |
WithMaskValue(string maskValue) | Sets the replacement value shown after the encryption key is deleted (crypto-shredding). |
WithMaskAfter(int count) | Keeps the first N characters, masks the rest with *. |
WithMaskBefore(int count) | Keeps the last N characters, masks the rest with *. |
WithMaskEmailDomain() | Keeps the local part of an email, masks the domain. |
WithMask(Func<string, string> mask) | Adds a custom inline masking function. See Custom Masking. |
e.PersonalData(c => c.Email)
.WithGroup("owner")
.WithMaskValue("[removed]")
.WithMaskEmailDomain();Collections
.PersonalData() works with collection properties (List<string>, string[], IList<string>, etc.) — Tayra automatically detects the collection type at runtime and encrypts each element individually. No separate method is needed.
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].
| Method | Description |
|---|---|
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. |
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>) — Tayra automatically detects the collection type at runtime and recurses into each element. No separate method is needed.
SerializedPersonalDataBuilder<T>
Configures encryption for non-string properties (int, DateTime, etc.) by serializing the value to binary and storing the ciphertext in a companion byte[]? property. Equivalent to [SerializedPersonalData].
| Method | Description |
|---|---|
WithGroup(string group) | Associates the field with an encryption group. |
StoredIn(c => c.DobEncrypted) | Specifies the companion byte[]? property where the encrypted bytes are stored. Defaults to {PropertyName}Encrypted by convention. |
e.SerializedPersonalData(c => c.DateOfBirth)
.WithGroup("owner")
.StoredIn(c => c.DateOfBirthEncrypted);BlindIndexBuilder<T>
Configures an HMAC blind index on a string property for equality queries on encrypted data. Equivalent to [BlindIndex].
| Method | Description |
|---|---|
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. |
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():
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.
CompoundBlindIndexBuilder<T>
Configures a compound blind index that combines multiple fields into a single HMAC hash. Equivalent to [CompoundBlindIndex].
| Method | Description |
|---|---|
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>:
| Method | Description |
|---|---|
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>. |
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.
| Attribute | Fluent 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(MaskValue = "[removed]")] | e.PersonalData(c => c.Name).WithMaskValue("[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) |
[SerializedPersonalData] | e.SerializedPersonalData(c => c.DateOfBirth) |
[BlindIndex(Transforms = ["lowercase"])] | e.BlindIndex(c => c.Email).WithLowercase() |
[CompoundBlindIndex("FullNameIndex", ...)] | e.CompoundBlindIndex("FullNameIndex").Field(c => c.FirstName).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.
// 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)
.WithMaskValue("[redacted]");
});
});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
- Attributes Overview -- Attribute-based PII marking
- Dependency Injection -- Service registration details
- Blind Indexes -- Searchable encryption with HMAC hashes
- Partial Redaction -- Redaction modes for crypto-shredding
