Attributes Overview
Tayra uses attributes to declaratively mark personal data on your model classes. At runtime, the metadata cache scans your types via reflection and drives the encryption engine automatically.
Fluent API Alternative
All attributes have fluent API equivalents that can be used instead of (or alongside) attributes. The fluent API is useful when you cannot modify the model classes directly, or when you prefer centralized configuration. The expression selectors accept either a property expression (x => x.SomeProperty) or a field expression (x => x.SomeField).
| Attribute | Fluent Equivalent | Builder Options |
|---|---|---|
[DataSubjectId] | e.DataSubjectId(x => x.Prop) | .WithGroup(), .WithPrefix() |
[PersonalData] | e.PersonalData(x => x.Prop) | .WithGroup(), .WithReplacement(), .StoredIn() (non-string companion), and string-only: .WithMaskAfter(), .WithMaskBefore(), .WithMaskEmailDomain(), .WithMask() |
[DeepPersonalData] | e.DeepPersonalData(x => x.Prop) | - |
[BlindIndex] | e.BlindIndex(x => x.Prop) | .WithLowercase(), .WithTrim(), .WithTransform(), .StoredIn(), .WithScope(), .WithBitLength() |
[CompoundBlindIndex] | e.CompoundBlindIndex("name") | .Field(), .StoredIn(), .WithScope(), .WithBitLength() |
[ArrayBlindIndex] | - | (attribute mirrors [BlindIndex]: IndexName, IndexPropertyName, Scope, BitLength, Transforms) |
See Fluent API for complete documentation.
Attribute Summary
| Attribute | Target | Purpose |
|---|---|---|
[PersonalData] | Any serializable property or field (string, string collections, int, DateTime, enums, records, ...) | Marks a member for AES-256-GCM encryption; strings encrypt in place, non-string members serialize into a companion byte[] |
[DataSubjectId] | Guid or string properties or fields | Identifies the data owner; derives the encryption key ID |
[DeepPersonalData] | Class-type properties or fields | Recurses into nested objects to encrypt their [PersonalData] members |
[BlindIndex] | Encrypted properties or fields of any canonicalizable type (string, int, DateOnly, Guid, enums, ...) | Computes an HMAC hash for equality queries on encrypted fields |
[CompoundBlindIndex] | Classes | Combines multiple members into a single HMAC hash for multi-field lookups |
[ArrayBlindIndex] | String collection properties or fields (string[], List<string>, HashSet<string>, ...) with [PersonalData] | Computes one HMAC hash per element into a same-shape companion collection for Contains / Any queries |
Example
Here is a typical annotated model:
public class Customer
{
[DataSubjectId]
public Guid Id { get; set; }
[PersonalData]
public string Name { get; set; } = "";
[PersonalData(ReplacementValue = "redacted@example.com")]
public string Email { get; set; } = "";
/// <summary>
/// Not annotated - stored and retrieved as plaintext.
/// </summary>
public string AccountType { get; set; } = "";
}In this example:
Idis the data subject identifier. Tayra derives the encryption key ID from this property's value.NameandEmailare personal data fields that will be encrypted in-place.Emailspecifies a customReplacementValuefor after crypto-shredding.AccountTypehas no annotation and is never encrypted.
How Metadata Discovery Works
When Tayra first encounters a type (via EncryptAsync<T> or DecryptAsync<T>), the PersonalDataMetadataCache scans the type using reflection:
- Member scan - All public instance properties and public instance fields are inspected for attributes.
- Subject resolution - Members with
[DataSubjectId]are recorded, including their group and prefix. - Field classification - Members with
[PersonalData]or[DeepPersonalData]are classified by kind based on the member type:Text(string),TextCollection(string collection),Serialized(any other type, into a companionbyte[]),Deep, orDeepCollection. - Caching - The result is stored in a
ConcurrentDictionarykeyed byType. Subsequent calls for the same type return the cached metadata with no reflection overhead.
The metadata cache is thread-safe and is registered as a singleton. The reflection cost is paid only once per type for the lifetime of the application.
Properties or Public Instance Fields
All six attributes ([PersonalData], [DataSubjectId], [DeepPersonalData], [BlindIndex], [CompoundBlindIndex], [ArrayBlindIndex]) can be applied to a property or a public instance field. Only public instance members are scanned - private and static members are ignored, exactly like the property scan rule.
Members that Tayra writes back to ([PersonalData] strings, [PersonalData] non-string values, and their companions) must be writable: a settable property (init-only is fine) or a non-readonly, non-const field. A get-only property or a readonly/const annotated field fails fast with a clear InvalidOperationException the first time the type is encrypted or decrypted. Deep objects and collections are mutated in place and do not need a setter.
Properties are the default idiom in the examples throughout these docs, but a field works identically:
public class Customer
{
[DataSubjectId]
public Guid Id; // public instance field
[PersonalData]
public string Email = ""; // encrypted in place, just like a property
}Records and init-only members
Tayra works with immutable C# records and init-only properties - you do not need mutable set accessors, and you do not need to migrate records to classes to adopt encryption. This matters for event-sourced systems and DTOs, where events and read models are typically records with required init-only members.
An init accessor counts as writable, so Tayra writes ciphertext (and, after crypto-shredding, replacement values) back through it via reflection. required is fully supported and is orthogonal to writability - a required property still has a setter.
public sealed record CustomerRegistered
{
[DataSubjectId]
public required string CustomerId { get; init; }
// string PII: encrypted in place through the init setter
[PersonalData]
public required string Email { get; init; }
// value-type PII: ciphertext goes to the companion, the original is zeroed on encrypt
[PersonalData]
public required DateOnly DateOfBirth { get; init; }
public byte[]? DateOfBirthEncrypted { get; init; }
}What is and isn't allowed:
- Allowed:
{ get; set; },{ get; init; }(includingrequired), and non-readonly, non-constfields. For a non-string[PersonalData]member, the companionbyte[]?member must also be writable (init-only is fine). - Not allowed for members Tayra writes back (
[PersonalData]strings and non-string values, plus their companions): get-only properties ({ get; }), expression-bodied properties, andreadonly/constfields. These fail fast with a clearInvalidOperationExceptionthe first time the type is encrypted or decrypted, rather than silently skipping the field. - No setter required:
[DeepPersonalData]nested objects and[PersonalData]string collections are mutated in place, so their containing members do not need a setter (though the collection instance itself must be mutable - see Collection Encryption).
Positional records
Positional record parameters (record Customer(string Name)) generate init-only properties, so they are writable and work with Tayra. Apply the attributes to the generated properties using the property: target, e.g. [property: PersonalData].
Roslyn Analyzers
The Tayra.Core package includes Roslyn analyzers that validate attribute usage at compile time:
| Rule | Severity | Description |
|---|---|---|
| TAYRA001 | Warning | Entity with [PersonalData] must have a [DataSubjectId] property |
| TAYRA002 | Info | [DataSubjectId] without [PersonalData] fields is unused |
| TAYRA003 | Error | [DeepPersonalData] must be on a class or record type |
| TAYRA004 | Warning | Multiple [DataSubjectId] properties require Group |
| TAYRA005 | Warning | [BlindIndex] without companion property (e.g. EmailIndex) |
| TAYRA006 | Warning | [BlindIndex] on a low-cardinality member (bool, small enum) |
| TAYRA011 | Warning | [BinaryEvent] type has [PersonalData] fields |
| TAYRA007 | Warning | [PersonalData] member mapped into a flat-table column |
| TAYRA008 | Warning | [PersonalData] member configured as a duplicated field |
| TAYRA009 | Warning | [PersonalData] member on a Wolverine saga |
| TAYRA010 | Warning | [ArrayBlindIndex] companion shape mismatch or missing companion |
| TAYRA013 | Error | Non-string [PersonalData] member has no companion byte[] to hold the ciphertext |
| TAYRA014 | Error | Masking set on a non-string [PersonalData] member (masking is string-only) |
| TAYRA015 | Warning | Encrypted member compared as cleartext in a query predicate (silently matches zero rows) |
These analyzers catch common mistakes before your code runs. No separate package installation is needed.
See Also
[PersonalData]- Encryption of string fields[DataSubjectId]- Key derivation and grouping[DeepPersonalData]- Nested object encryption[BlindIndex]- HMAC blind indexes for searchable encryption[ArrayBlindIndex]- Blind indexes over encrypted string collections- Getting Started - End-to-end tutorial
