Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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:

  1. Injects properties via a generated partial class (e.g., CreatedAt, UpdatedAt)
  2. Generates EF Core configuration (column types, defaults, indexes, query filters)
  3. 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
}

Generated Partial Class

// 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();
}

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

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"
)]

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
}

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

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

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

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();

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

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

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

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

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

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

Registered in DI:

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
}

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; } = "";
}

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();
}

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

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
}

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

Generated Configuration

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 = @p3

If 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
}

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

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; } = "";
}

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

The SaveChanges hooks dispatch to all applicable behaviors. On insert:

  1. CreatedAt and UpdatedAt are stamped (Timestampable)
  2. Slug is generated from Name (Sluggable)
  3. IsDeleted defaults to false (SoftDeletable — via database default)

On delete:

  1. IsDeleted is set to true (SoftDeletable)
  2. DeletedAt is stamped (SoftDeletable)
  3. UpdatedAt is 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.cs files
  • 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.