Behaviors
"A behavior is a cross-cutting concern expressed as a single attribute. The generator injects the properties, the configuration, and the SaveChanges hook. The developer writes nothing."
What Are Behaviors?
Behaviors are opt-in, class-level attributes that add functionality to an entity without the developer writing any boilerplate. Each behavior:
- Injects properties via a generated partial class (e.g.,
CreatedAt,UpdatedAt) - Generates EF Core configuration (column types, defaults, indexes, query filters)
- Hooks into SaveChanges to automatically set property values on insert, update, or delete
The pattern is inspired by PHP Doctrine's Gedmo extensions — Timestampable, SoftDeletable, Sluggable — which were among the most popular Doctrine behaviors. Entity.Dsl implements the same concepts with C# source generation instead of runtime reflection.
The key difference: Doctrine behaviors use runtime listeners and proxy classes. Entity.Dsl behaviors generate compile-time code. There is no runtime cost, no proxy magic, and no hidden behavior. Everything the behavior does is visible in the generated .g.cs files.
[Timestampable] — Creation and Modification Tracking
The most common behavior. Automatically records when an entity was created and last modified.
Usage
[AggregateRoot("Product")]
[Table("Products")]
[Timestampable]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
// ... other properties — no CreatedAt/UpdatedAt needed
}[AggregateRoot("Product")]
[Table("Products")]
[Timestampable]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
// ... other properties — no CreatedAt/UpdatedAt needed
}Generated Partial Class
// Product.Behaviors.g.cs
public partial class Product
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}// Product.Behaviors.g.cs
public partial class Product
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}Generated Configuration
protected virtual void ConfigureTimestampable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.UpdatedAt).IsRequired();
}protected virtual void ConfigureTimestampable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.CreatedAt).IsRequired();
builder.Property(e => e.UpdatedAt).IsRequired();
}Generated SaveChanges Hook
In MarketplaceDbContextBase.g.cs, the OnEntitiesAdding and OnEntitiesModifying hooks stamp the timestamps:
protected override void OnEntitiesAdding(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries)
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in entries)
{
if (entry.Entity is ITimestampable timestampable)
{
timestampable.CreatedAt = now;
timestampable.UpdatedAt = now;
}
}
}
protected override void OnEntitiesModifying(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries)
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in entries)
{
if (entry.Entity is ITimestampable timestampable)
{
timestampable.UpdatedAt = now;
}
}
}protected override void OnEntitiesAdding(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries)
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in entries)
{
if (entry.Entity is ITimestampable timestampable)
{
timestampable.CreatedAt = now;
timestampable.UpdatedAt = now;
}
}
}
protected override void OnEntitiesModifying(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries)
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in entries)
{
if (entry.Entity is ITimestampable timestampable)
{
timestampable.UpdatedAt = now;
}
}
}The generator also emits an ITimestampable interface on the entity, enabling the type check in the hook.
Configuration Options
[Timestampable(
CreatedAtName = "DateCreated", // custom property name (default: "CreatedAt")
UpdatedAtName = "DateModified", // custom property name (default: "UpdatedAt")
Type = "DateTime", // "DateTimeOffset" (default), "DateTime", "long" (unix ticks)
Precision = 3, // fractional seconds: 0-7 (default: 7 = 100ns)
TimeZone = "Utc", // "Utc" (default), "Local", "Unspecified"
CreatedAtImmutable = true, // never overwrite after first set (default: true)
UpdateOnChildChange = false, // update parent when [Composition] children change (default: false)
CreatedAtColumnName = "created_at", // explicit column name
UpdatedAtColumnName = "updated_at"
)][Timestampable(
CreatedAtName = "DateCreated", // custom property name (default: "CreatedAt")
UpdatedAtName = "DateModified", // custom property name (default: "UpdatedAt")
Type = "DateTime", // "DateTimeOffset" (default), "DateTime", "long" (unix ticks)
Precision = 3, // fractional seconds: 0-7 (default: 7 = 100ns)
TimeZone = "Utc", // "Utc" (default), "Local", "Unspecified"
CreatedAtImmutable = true, // never overwrite after first set (default: true)
UpdateOnChildChange = false, // update parent when [Composition] children change (default: false)
CreatedAtColumnName = "created_at", // explicit column name
UpdatedAtColumnName = "updated_at"
)]When UpdateOnChildChange = true, modifying an OrderItem also updates the parent Order's UpdatedAt. This is useful for aggregate roots where the "last modified" timestamp should reflect changes to any child entity.
[SoftDeletable] — Logical Deletion
Instead of physically deleting rows, soft delete marks them as deleted and filters them from queries.
Usage
[AggregateRoot("Product")]
[Table("Products")]
[Timestampable]
[SoftDeletable]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
// ... other properties
}[AggregateRoot("Product")]
[Table("Products")]
[Timestampable]
[SoftDeletable]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
// ... other properties
}Generated Partial Class
// Product.Behaviors.g.cs
public partial class Product
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}// Product.Behaviors.g.cs
public partial class Product
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}Generated Configuration
protected virtual void ConfigureSoftDeletable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.IsDeleted).IsRequired().HasDefaultValue(false);
builder.Property(e => e.DeletedAt);
builder.HasQueryFilter(e => !e.IsDeleted);
}protected virtual void ConfigureSoftDeletable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.IsDeleted).IsRequired().HasDefaultValue(false);
builder.Property(e => e.DeletedAt);
builder.HasQueryFilter(e => !e.IsDeleted);
}The HasQueryFilter is the critical line — it adds a global query filter that automatically excludes soft-deleted entities from all queries. _uow.Products.FindAllAsync() only returns non-deleted products.
Generated SaveChanges Hook
protected override void OnEntitiesDeleting(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries)
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in entries)
{
if (entry.Metadata.FindProperty("IsDeleted") != null)
{
entry.State = EntityState.Modified;
entry.Property("IsDeleted").CurrentValue = true;
entry.Property("DeletedAt").CurrentValue = now;
}
}
}protected override void OnEntitiesDeleting(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries)
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in entries)
{
if (entry.Metadata.FindProperty("IsDeleted") != null)
{
entry.State = EntityState.Modified;
entry.Property("IsDeleted").CurrentValue = true;
entry.Property("DeletedAt").CurrentValue = now;
}
}
}This intercepts Remove() calls. Instead of deleting the row, it changes the entity state from Deleted to Modified, sets IsDeleted = true, and records the deletion timestamp. The database never sees a DELETE statement for soft-deletable entities.
Querying Deleted Entities
To include soft-deleted entities (e.g., for admin views or audit trails):
// Bypass the global filter
var allProducts = await _uow.Products.Query
.IgnoreQueryFilters()
.ToListAsync();
// Only deleted products
var deletedProducts = await _uow.Products.Query
.IgnoreQueryFilters()
.Where(p => p.IsDeleted)
.ToListAsync();// Bypass the global filter
var allProducts = await _uow.Products.Query
.IgnoreQueryFilters()
.ToListAsync();
// Only deleted products
var deletedProducts = await _uow.Products.Query
.IgnoreQueryFilters()
.Where(p => p.IsDeleted)
.ToListAsync();Configuration Options
[SoftDeletable(
IsDeletedName = "Archived", // custom property name (default: "IsDeleted")
DeletedAtName = "ArchivedAt", // custom property name (default: "DeletedAt")
CascadeToChildren = true, // soft-delete [Composition] children too (default: true)
AllowHardDelete = false, // generates HardDelete() on repository (default: false)
FilterEnabled = true, // auto-add HasQueryFilter (default: true)
AllowRestore = true // generates Restore() method (default: true)
)][SoftDeletable(
IsDeletedName = "Archived", // custom property name (default: "IsDeleted")
DeletedAtName = "ArchivedAt", // custom property name (default: "DeletedAt")
CascadeToChildren = true, // soft-delete [Composition] children too (default: true)
AllowHardDelete = false, // generates HardDelete() on repository (default: false)
FilterEnabled = true, // auto-add HasQueryFilter (default: true)
AllowRestore = true // generates Restore() method (default: true)
)]When CascadeToChildren = true and a Product is soft-deleted, all its ProductVariants (composition children) are also soft-deleted.
When AllowRestore = true, the generated repository includes:
public async Task RestoreAsync(Guid id)
{
var entity = await Query.IgnoreQueryFilters()
.FirstOrDefaultAsync(e => e.Id == id);
if (entity != null)
{
entity.IsDeleted = false;
entity.DeletedAt = null;
}
}public async Task RestoreAsync(Guid id)
{
var entity = await Query.IgnoreQueryFilters()
.FirstOrDefaultAsync(e => e.Id == id);
if (entity != null)
{
entity.IsDeleted = false;
entity.DeletedAt = null;
}
}[Blameable] — User Audit Trail
Tracks which user created and last modified an entity. Requires an ICurrentUserProvider implementation in DI.
Usage
[AggregateRoot("Order")]
[Table("Orders")]
[Timestampable]
[Blameable]
public partial class Order
{
[PrimaryKey]
public Guid Id { get; set; }
// ... other properties
}[AggregateRoot("Order")]
[Table("Orders")]
[Timestampable]
[Blameable]
public partial class Order
{
[PrimaryKey]
public Guid Id { get; set; }
// ... other properties
}Generated Partial Class
// Order.Behaviors.g.cs
public partial class Order
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
}// Order.Behaviors.g.cs
public partial class Order
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
}Generated SaveChanges Hook
protected override void OnEntitiesAdding(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries)
{
var now = DateTimeOffset.UtcNow;
var currentUser = _serviceProvider?.GetService<ICurrentUserProvider>()?.CurrentUserId;
foreach (var entry in entries)
{
if (entry.Entity is ITimestampable timestampable)
{
timestampable.CreatedAt = now;
timestampable.UpdatedAt = now;
}
if (entry.Entity is IBlameable blameable)
{
blameable.CreatedBy = currentUser;
blameable.UpdatedBy = currentUser;
}
}
}protected override void OnEntitiesAdding(
global::System.Collections.Generic.IEnumerable<Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry> entries)
{
var now = DateTimeOffset.UtcNow;
var currentUser = _serviceProvider?.GetService<ICurrentUserProvider>()?.CurrentUserId;
foreach (var entry in entries)
{
if (entry.Entity is ITimestampable timestampable)
{
timestampable.CreatedAt = now;
timestampable.UpdatedAt = now;
}
if (entry.Entity is IBlameable blameable)
{
blameable.CreatedBy = currentUser;
blameable.UpdatedBy = currentUser;
}
}
}ICurrentUserProvider
The developer provides the implementation:
public class HttpContextUserProvider : ICurrentUserProvider
{
private readonly IHttpContextAccessor _accessor;
public HttpContextUserProvider(IHttpContextAccessor accessor) => _accessor = accessor;
public string? CurrentUserId
=> _accessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}public class HttpContextUserProvider : ICurrentUserProvider
{
private readonly IHttpContextAccessor _accessor;
public HttpContextUserProvider(IHttpContextAccessor accessor) => _accessor = accessor;
public string? CurrentUserId
=> _accessor.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}Registered in DI:
services.AddScoped<ICurrentUserProvider, HttpContextUserProvider>();services.AddScoped<ICurrentUserProvider, HttpContextUserProvider>();[Sluggable] — URL-Friendly Identifiers
Generates a URL slug from a source property. The slug is automatically created on insert and optionally updated on modification.
Usage
[AggregateRoot("Product")]
[Table("Products")]
[Timestampable]
[SoftDeletable]
[Sluggable("Name")]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
// ... other properties
}[AggregateRoot("Product")]
[Table("Products")]
[Timestampable]
[SoftDeletable]
[Sluggable("Name")]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
// ... other properties
}Generated Partial Class
// Product.Behaviors.g.cs
public partial class Product
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
[global::System.ComponentModel.DataAnnotations.MaxLength(200)]
public string Slug { get; set; } = "";
}// Product.Behaviors.g.cs
public partial class Product
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
[global::System.ComponentModel.DataAnnotations.MaxLength(200)]
public string Slug { get; set; } = "";
}Generated Configuration
protected virtual void ConfigureSluggable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Slug).IsRequired().HasMaxLength(200);
builder.HasIndex(e => e.Slug).IsUnique();
}protected virtual void ConfigureSluggable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Product> builder)
{
builder.Property(e => e.Slug).IsRequired().HasMaxLength(200);
builder.HasIndex(e => e.Slug).IsUnique();
}The unique index ensures no two products have the same slug.
Generated SaveChanges Hook
foreach (var entry in entries)
{
if (entry.Entity is Product product && entry.State == EntityState.Added)
{
product.Slug = SlugHelper.ToSlug(product.Name);
}
}foreach (var entry in entries)
{
if (entry.Entity is Product product && entry.State == EntityState.Added)
{
product.Slug = SlugHelper.ToSlug(product.Name);
}
}The SlugHelper.ToSlug() method (from Entity.Dsl.Abstractions) converts:
"Samsung Galaxy S24 Ultra"→"samsung-galaxy-s24-ultra""iPhone 16 Pro Max (256GB)"→"iphone-16-pro-max-256gb""Cafe au Lait — Special Edition"→"cafe-au-lait-special-edition"
It handles Unicode normalization, diacritics removal, special characters, and consecutive hyphens.
[Versionable] — Optimistic Concurrency
Adds a concurrency token to detect conflicting updates. When two users modify the same entity concurrently, the second save throws a DbUpdateConcurrencyException.
Usage
[AggregateRoot("Store")]
[Table("Stores")]
[Timestampable]
[Versionable]
public partial class Store
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
// ... other properties
}[AggregateRoot("Store")]
[Table("Stores")]
[Timestampable]
[Versionable]
public partial class Store
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
// ... other properties
}Generated Partial Class
// Store.Behaviors.g.cs
public partial class Store
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
[global::System.ComponentModel.DataAnnotations.Timestamp]
public byte[] RowVersion { get; set; } = null!;
}// Store.Behaviors.g.cs
public partial class Store
{
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
[global::System.ComponentModel.DataAnnotations.Timestamp]
public byte[] RowVersion { get; set; } = null!;
}Generated Configuration
protected virtual void ConfigureVersionable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Store> builder)
{
builder.Property(e => e.RowVersion).IsRowVersion();
}protected virtual void ConfigureVersionable(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Store> builder)
{
builder.Property(e => e.RowVersion).IsRowVersion();
}How It Works
EF Core includes the RowVersion value in the WHERE clause of UPDATE statements:
UPDATE Stores SET Name = @p0, UpdatedAt = @p1
WHERE Id = @p2 AND RowVersion = @p3UPDATE Stores SET Name = @p0, UpdatedAt = @p1
WHERE Id = @p2 AND RowVersion = @p3If the row version has changed since the entity was loaded (because another user modified it), the WHERE clause matches zero rows, and EF Core throws DbUpdateConcurrencyException.
The developer handles the conflict:
try
{
await _uow.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// Reload from database and re-apply changes, or show conflict UI
var entry = ex.Entries.Single();
await entry.ReloadAsync();
// ... resolve conflict
}try
{
await _uow.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// Reload from database and re-apply changes, or show conflict UI
var entry = ex.Entries.Single();
await entry.ReloadAsync();
// ... resolve conflict
}Composability
Behaviors compose freely. Product has three behaviors applied simultaneously:
[AggregateRoot("Product")]
[Table("Products")]
[Timestampable]
[SoftDeletable]
[Sluggable("Name")]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
[Precision(18, 2)]
public decimal Price { get; set; }
}[AggregateRoot("Product")]
[Table("Products")]
[Timestampable]
[SoftDeletable]
[Sluggable("Name")]
public partial class Product
{
[PrimaryKey]
public Guid Id { get; set; }
[Required]
[MaxLength(200)]
public string Name { get; set; } = "";
[Precision(18, 2)]
public decimal Price { get; set; }
}The generated partial class merges all behavior properties:
// Product.Behaviors.g.cs
public partial class Product
{
// From [Timestampable]
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
// From [SoftDeletable]
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
// From [Sluggable]
public string Slug { get; set; } = "";
}// Product.Behaviors.g.cs
public partial class Product
{
// From [Timestampable]
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
// From [SoftDeletable]
public bool IsDeleted { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
// From [Sluggable]
public string Slug { get; set; } = "";
}The generated configuration merges all behavior configurations:
// In the orchestrator
public void Configure(EntityTypeBuilder<Product> builder)
{
PreConfigure(builder);
ConfigureTable(builder);
ConfigurePrimaryKey(builder);
ConfigureName(builder);
ConfigurePrice(builder);
ConfigureTimestampable(builder); // ← from [Timestampable]
ConfigureSoftDeletable(builder); // ← from [SoftDeletable]
ConfigureSluggable(builder); // ← from [Sluggable]
PostConfigure(builder);
}// In the orchestrator
public void Configure(EntityTypeBuilder<Product> builder)
{
PreConfigure(builder);
ConfigureTable(builder);
ConfigurePrimaryKey(builder);
ConfigureName(builder);
ConfigurePrice(builder);
ConfigureTimestampable(builder); // ← from [Timestampable]
ConfigureSoftDeletable(builder); // ← from [SoftDeletable]
ConfigureSluggable(builder); // ← from [Sluggable]
PostConfigure(builder);
}The SaveChanges hooks dispatch to all applicable behaviors. On insert:
CreatedAtandUpdatedAtare stamped (Timestampable)Slugis generated fromName(Sluggable)IsDeleteddefaults tofalse(SoftDeletable — via database default)
On delete:
IsDeletedis set totrue(SoftDeletable)DeletedAtis stamped (SoftDeletable)UpdatedAtis stamped (Timestampable — the modification is tracked)
No conflicts. No ordering issues. Each behavior is independent and composable.
The Behavior Pattern Summary
| Behavior | Properties Injected | Configuration | SaveChanges Hook | Requires |
|---|---|---|---|---|
[Timestampable] |
CreatedAt, UpdatedAt | IsRequired | Stamp on Add/Modify | Nothing |
[SoftDeletable] |
IsDeleted, DeletedAt | HasDefaultValue, HasQueryFilter | Intercept Delete → Modify | Nothing |
[Blameable] |
CreatedBy, UpdatedBy | HasMaxLength | Read from ICurrentUserProvider | ICurrentUserProvider in DI |
[Sluggable] |
Slug | IsRequired, HasIndex(unique) | Generate from source property | Nothing |
[Versionable] |
RowVersion | IsRowVersion | Managed by EF Core | Nothing |
Every behavior:
- Adds zero manual code to the entity
- Generates visible, debuggable
.g.csfiles - Hooks into the DbContext's lifecycle methods
- Can be overridden via the Generation Gap pattern (override
ConfigureTimestampable, etc.)
In the next part, we add inheritance to the domain — the Payment hierarchy with TPH, TPT, and TPC strategies.