Skip to content

Advanced Features

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.

[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
}
SettingGenerated MethodWhat It Does
MaxActive > 0ValidateTemporalConstraints(existing)Checks how many records are currently active. Returns a TemporalOverlapError if the limit is exceeded.
MaxActive == 1AutoClosePrevious(existing, closedAt)Finds the currently active record, sets its ValidTo to closedAt, and returns the modified records so you can save them.
AllowOverlap = falseOverlap check in ValidateTemporalConstraintsChecks if the new record’s validity period overlaps with any existing record.
// Close the previous role and assign a new one
var 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);
ParameterTypeDescription
MaxActiveintMaximum number of simultaneously active records. 0 = unlimited.
AllowOverlapboolWhether validity periods can overlap. Default: false.

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.


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.

// 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; }
}
StrategyHow It WorksBest For
Tph (Table-per-Hierarchy)All types in one table with a discriminator columnSimple hierarchies, fast queries
Tpt (Table-per-Type)Separate table per type, joined by FKMany subtype-specific columns
Tpc (Table-per-Concrete)Only concrete types have tables (no base table)Querying each subtype independently
// ═══ 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.


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).

[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
}
// ═══ 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.


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; }
}
// ═══ 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.


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.

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.


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.

[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; }
}

The generator creates a GetTimeline() extension method that returns all records for a parent, ordered chronologically:

RoomRate.Timeline.g.cs
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.


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.

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; } = "";
}
  1. [CascadeSource] on RoomType.Name → the generated setter raises an EntityPropertyChanged domain event
  2. The target assembly’s [CascadeOn<RoomType>] → the SG generates an event handler that updates LineItem.RoomTypeName
  3. Within the same assembly, [CascadeOn<T>] works without [CascadeSource] — the SG can see both sides directly

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.

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);
}
}
  1. [HasPresets] tells the SG this entity has preset children
  2. [PresetProvider<T>] registers one or more providers (ordered, AllowMultiple)
  3. When the entity is created (via Create() factory or mutation), the pipeline calls each provider
  4. All returned entities are added to the DbContext and saved together
  5. Providers receive a LifecycleContext with current user, tenant, and timestamp

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.

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 DI
services.AddScoped<IEntityLifecycle<Order>, OrderLifecycle>();
HookWhenUse Case
OnCreatingBefore validation, after Create()Set computed defaults, normalize data
OnSavingAfter validation, before SaveChangesFinal computed fields, cross-field consistency

Both hooks receive a LifecycleContext with:

  • Now — current UTC time (uses TimeProvider for testability)
  • UserId — current user ID (if authenticated)
  • TenantId — current tenant ID (if multi-tenant)
  • Metadata — custom key-value data for the lifecycle scope

When processing large volumes of entities (imports, migrations, bulk creation), you want to:

  1. Chunk SaveChanges calls (not save 100,000 entities in one transaction)
  2. Defer domain events until the batch completes
  3. Track accumulated entities across the batch
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 events
var 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).