Advanced Features
Temporal Relations
Section titled “Temporal Relations”The Problem
Section titled “The Problem”Some entities represent data that is valid only for a period of time. For example:
- A user’s role is valid from January 1st to December 31st
- An employee’s salary is effective from their hire date until a raise
- A room rate applies from check-in to check-out
You need to track when a record is active (ValidFrom / ValidTo), enforce rules like “only one active record at a time” (a user can’t have two active roles simultaneously), and detect overlapping periods.
The Solution: [TemporalRelation<TParent>]
Section titled “The Solution: [TemporalRelation<TParent>]”[Entity<Guid>][TemporalRelation<User>(MaxActive = 1, AllowOverlap = false)][BelongsTo<IdentityBoundary>]public partial class UserRole{ public Guid UserId { get; private set; } public string RoleName { get; private set; } = ""; public DateTimeOffset ValidFrom { get; private set; } public DateTimeOffset? ValidTo { get; private set; } // null = currently active}What Gets Generated
Section titled “What Gets Generated”| Setting | Generated Method | What It Does |
|---|---|---|
MaxActive > 0 | ValidateTemporalConstraints(existing) | Checks how many records are currently active. Returns a TemporalOverlapError if the limit is exceeded. |
MaxActive == 1 | AutoClosePrevious(existing, closedAt) | Finds the currently active record, sets its ValidTo to closedAt, and returns the modified records so you can save them. |
AllowOverlap = false | Overlap check in ValidateTemporalConstraints | Checks if the new record’s validity period overlaps with any existing record. |
Example: Assigning a New Role
Section titled “Example: Assigning a New Role”// Close the previous role and assign a new onevar closedRecords = UserRole.AutoClosePrevious( db.UserRoles.AsQueryable(), // Existing records DateTimeOffset.UtcNow, // Close timestamp userId); // Scope to this user
// closedRecords contains the previously active role(s) with ValidTo set// Save them alongside the new role
var newRole = UserRole.Create(userId, "Admin", DateTimeOffset.UtcNow);db.UserRoles.Add(newRole);await db.SaveChangesAsync(ct);Parameters
Section titled “Parameters”| Parameter | Type | Description |
|---|---|---|
MaxActive | int | Maximum number of simultaneously active records. 0 = unlimited. |
AllowOverlap | bool | Whether validity periods can overlap. Default: false. |
Automatic Query Filter
Section titled “Automatic Query Filter”A TemporalFilter_{Type} is automatically generated and registered. It filters queries to return only currently active records (ValidTo == null || ValidTo > Now). This can be disabled via IQueryFilterToggle when you need to see historical records.
Inheritance
Section titled “Inheritance”The Problem
Section titled “The Problem”Some entities form a hierarchy — ServiceFee and CancellationFee are both types of Fee. In a relational database, you need to choose how to store this hierarchy: all in one table (TPH), separate tables joined by FK (TPT), or only concrete types (TPC). Each strategy has trade-offs.
The Solution: [Inheritance]
Section titled “The Solution: [Inheritance]”// Base class — abstract, no Create() generated[Entity<Guid>][Inheritance(InheritanceStrategy.Tph, DiscriminatorColumn = "FeeType")][BelongsTo<BillingBoundary>]public abstract partial class Fee{ public decimal Amount { get; private set; } public string Currency { get; private set; } = "EUR";}
// Derived classes — concrete, Create() generated for each[Entity<Guid>]public partial class ServiceFee : Fee{ public string Description { get; private set; } = "";}
[Entity<Guid>]public partial class CancellationFee : Fee{ public string Reason { get; private set; } = ""; public decimal Penalty { get; private set; }}Strategies
Section titled “Strategies”| Strategy | How It Works | Best For |
|---|---|---|
Tph (Table-per-Hierarchy) | All types in one table with a discriminator column | Simple hierarchies, fast queries |
Tpt (Table-per-Type) | Separate table per type, joined by FK | Many subtype-specific columns |
Tpc (Table-per-Concrete) | Only concrete types have tables (no base table) | Querying each subtype independently |
What Gets Generated
Section titled “What Gets Generated”// ═══ Generated: FeeInheritanceConfiguration.g.cs ═══internal static class FeeInheritanceConfiguration{ public static void Configure(ModelBuilder modelBuilder) { modelBuilder.Entity<Fee>() .HasDiscriminator<string>("FeeType") .HasValue<ServiceFee>("ServiceFee") .HasValue<CancellationFee>("CancellationFee"); }}This configuration is automatically called in the generated DbContext’s OnModelCreating.
Hierarchy (Self-Referencing Trees)
Section titled “Hierarchy (Self-Referencing Trees)”The Problem
Section titled “The Problem”Some entities form a tree: categories with subcategories, organizational units, comment threads. You need to query “all descendants of node X” or “all ancestors of node Y” — which requires recursive queries (CTEs in SQL).
The Solution: [GenerateHierarchy]
Section titled “The Solution: [GenerateHierarchy]”[Entity<Guid>][GenerateHierarchy][BelongsTo<CatalogBoundary>]public partial class Category{ public Guid? ParentId { get; private set; } // null = root node public string Name { get; private set; } = "";
[DefaultValue(0)] public int Level { get; private set; } // 0 = root, 1 = first level, etc. public string Path { get; private set; } = "/"; // Materialized path for breadcrumbs}What Gets Generated
Section titled “What Gets Generated”// ═══ Generated methods ═══
// Get all descendants of a category (uses SQL CTE)var subcategories = await Category.GetDescendants(dbContext, rootCategoryId);
// Get all ancestors (path from leaf to root)var breadcrumb = await Category.GetAncestors(dbContext, leafCategoryId);Use cases: Category trees, organizational charts, file system hierarchies, threaded comments.
Polymorphic Attachments
Section titled “Polymorphic Attachments”The Problem
Section titled “The Problem”A Document can belong to an Invoice, a Reservation, or any other entity. With traditional inheritance (TPH/TPT), you’d need a discriminator. With foreign keys, you’d need a separate FK for each possible owner — leading to many nullable columns.
The Solution: [PolymorphicAttachment] + [Attachable<T>]
Section titled “The Solution: [PolymorphicAttachment] + [Attachable<T>]”[Entity<Guid>][PolymorphicAttachment][Attachable<Invoice>][Attachable<Reservation>][BelongsTo<BillingBoundary>]public partial class Document{ public string FileName { get; private set; } = ""; public long FileSize { get; private set; }}What Gets Generated
Section titled “What Gets Generated”// ═══ Generated ═══public partial class Document{ public string OwnerType { get; private set; } = ""; // "Invoice" or "Reservation" public Guid OwnerId { get; private set; } // FK to the owner entity
public void AttachTo(Invoice invoice) { /* type-safe */ } public void AttachTo(Reservation reservation) { /* type-safe */ }}The OwnerType + OwnerId pattern stores the relationship. The generated AttachTo() methods are type-safe — you can only attach to entity types listed in [Attachable<T>]. Trying to attach to an unlisted type is a compile-time error.
Lifecycle Attributes
Section titled “Lifecycle Attributes”[DefaultValue]
Section titled “[DefaultValue]”Sets a static default value for a property when the entity is created:
[DefaultValue(true)]public bool IsActive { get; private set; }
[DefaultValue(1)]public int Quantity { get; private set; }[ComputedDefault<TEntity, TProp, TGenerator>]
Section titled “[ComputedDefault<TEntity, TProp, TGenerator>]”For defaults that need runtime computation — like generating sequential invoice numbers:
[ComputedDefault<Invoice, string, InvoiceNumberGenerator>]public string InvoiceNumber { get; private set; } = "";The InvoiceNumberGenerator implements IDefaultValueGenerator<Invoice, string>:
public class InvoiceNumberGenerator : IDefaultValueGenerator<Invoice, string>{ public Task<string> GenerateAsync(Invoice entity, CancellationToken ct) { // Your logic: query DB for next sequence, format string, etc. return Task.FromResult($"INV-{DateTime.UtcNow:yyyyMM}-{nextSeq:D5}"); }}The generated MutationInvoker automatically resolves the generator from DI and calls it during entity creation.
[CascadeOn<TTarget>]
Section titled “[CascadeOn<TTarget>]”Defines that when a related entity changes, this property should be updated:
[Entity<Guid>]public partial class LineItem{ /// <summary> /// When RoomType.BaseRate changes, this price is updated automatically. /// </summary> [CascadeOn<RoomType>(nameof(RoomType.BaseRate))] public decimal UnitPrice { get; private set; }}The source generator produces a cascade handler that listens for changes on the source property and propagates them to all affected entities.
[GenerateTimeline]
Section titled “[GenerateTimeline]”The Problem
Section titled “The Problem”Temporal entities track validity periods, but building a timeline view — showing all historical and current records in chronological order — requires writing complex queries with ordering and gap detection.
The Solution
Section titled “The Solution”[Entity<Guid>][TemporalRelation<Property>][GenerateTimeline]public partial class RoomRate{ public decimal NightlyRate { get; private set; } public DateTimeOffset ValidFrom { get; private set; } public DateTimeOffset? ValidTo { get; private set; }}What the Source Generator Produces
Section titled “What the Source Generator Produces”The generator creates a GetTimeline() extension method that returns all records for a parent, ordered chronologically:
public static class RoomRateTimelineExtensions{ public static IQueryable<RoomRate> GetTimeline( this IQueryable<RoomRate> query, Guid propertyId) { return query .Where(e => e.PropertyId == propertyId) .OrderBy(e => e.ValidFrom); }}Use it to display historical data in a UI or export reports.
[CascadeSource]
Section titled “[CascadeSource]”The Problem
Section titled “The Problem”When a property changes on one entity, related entities in other assemblies need to update their cached values. For example, when a RoomType.Name changes, all LineItem records referencing that room type should update their RoomTypeName.
Within the same project, [CascadeOn<T>] handles this automatically. But when the source and target entities are in different assemblies, the source generator can’t see both sides.
The Solution
Section titled “The Solution”Mark the source property with [CascadeSource]:
// In Showcase.Catalog assembly[Entity<Guid>]public partial class RoomType{ [CascadeSource] // Raises EntityPropertyChanged event when changed public string Name { get; private set; } = "";}// In Showcase.Billing assembly[Entity<Guid>]public partial class LineItem{ [CascadeOn<RoomType>(nameof(RoomType.Name))] public string RoomTypeName { get; private set; } = "";}How It Works
Section titled “How It Works”[CascadeSource]onRoomType.Name→ the generated setter raises anEntityPropertyChangeddomain event- The target assembly’s
[CascadeOn<RoomType>]→ the SG generates an event handler that updatesLineItem.RoomTypeName - Within the same assembly,
[CascadeOn<T>]works without[CascadeSource]— the SG can see both sides directly
[HasPresets] and [PresetProvider<T>]
Section titled “[HasPresets] and [PresetProvider<T>]”The Problem
Section titled “The Problem”Some entities need pre-created child records when they’re created. For example, when you create a new Hotel, it should automatically get standard room types (“Standard”, “Deluxe”, “Suite”) and default amenity categories. Writing this initialization logic manually is error-prone and scattered.
The Solution
Section titled “The Solution”Mark the parent entity:
[Entity<Guid>][HasPresets][PresetProvider<StandardRoomTypePresets>][PresetProvider<DefaultAmenityPresets>]public partial class Hotel{ public string Name { get; private set; } = "";}Implement the preset providers:
public class StandardRoomTypePresets : IPresetProvider<Hotel>{ public Task<IReadOnlyList<object>> CreatePresetsAsync( Hotel parent, LifecycleContext context, CancellationToken ct) { var presets = new List<object> { RoomType.Create("Standard", parent.PersistenceId, 1), RoomType.Create("Deluxe", parent.PersistenceId, 2), RoomType.Create("Suite", parent.PersistenceId, 3), }; return Task.FromResult<IReadOnlyList<object>>(presets); }}How It Works
Section titled “How It Works”[HasPresets]tells the SG this entity has preset children[PresetProvider<T>]registers one or more providers (ordered,AllowMultiple)- When the entity is created (via
Create()factory or mutation), the pipeline calls each provider - All returned entities are added to the
DbContextand saved together - Providers receive a
LifecycleContextwith current user, tenant, and timestamp
IEntityLifecycle<T>
Section titled “IEntityLifecycle<T>”The Problem
Section titled “The Problem”Sometimes you need to run logic when an entity is being created or saved — setting computed defaults, normalizing data, or enforcing invariants that can’t be expressed with attributes alone.
The Solution
Section titled “The Solution”public class OrderLifecycle : IEntityLifecycle<Order>{ public void OnCreating(Order entity, LifecycleContext context) { // Set defaults before validation if (string.IsNullOrEmpty(entity.Currency)) entity.SetCurrency("EUR"); }
public void OnSaving(Order entity, LifecycleContext context) { // Final modifications before save (after validation) entity.SetUpdatedAt(context.Now); }}
// Register in DIservices.AddScoped<IEntityLifecycle<Order>, OrderLifecycle>();Lifecycle Hooks
Section titled “Lifecycle Hooks”| Hook | When | Use Case |
|---|---|---|
OnCreating | Before validation, after Create() | Set computed defaults, normalize data |
OnSaving | After validation, before SaveChanges | Final computed fields, cross-field consistency |
Both hooks receive a LifecycleContext with:
Now— current UTC time (usesTimeProviderfor testability)UserId— current user ID (if authenticated)TenantId— current tenant ID (if multi-tenant)Metadata— custom key-value data for the lifecycle scope
BatchContext
Section titled “BatchContext”The Problem
Section titled “The Problem”When processing large volumes of entities (imports, migrations, bulk creation), you want to:
- Chunk
SaveChangescalls (not save 100,000 entities in one transaction) - Defer domain events until the batch completes
- Track accumulated entities across the batch
The Solution
Section titled “The Solution”using var batch = new BatchContext(new BulkOperationOptions{ ChunkSize = 500, // SaveChanges every 500 entities ChunkAsTransaction = true // Each chunk in its own transaction});
foreach (var row in importData){ var entity = Order.Create(row.Number, row.Total, row.CustomerId); batch.AccumulateEntity(entity);
// Events are deferred, not dispatched immediately batch.DeferEvent(new OrderImported(entity.PersistenceId));}
// After batch completes, process deferred eventsvar events = batch.GetEventsByType<OrderImported>();BatchContext uses AsyncLocal — it’s ambient, so any code running within its scope sees it via BatchContext.Current. Nested batch contexts are supported (each restores the previous on dispose).