Entity Attributes
Each attribute you place on an entity tells the source generator to produce specific infrastructure code. This page explains the problem each attribute solves, what it generates, and when to use it.
[Auditable]
Section titled “[Auditable]”Problem
Section titled “Problem”You need to know who created or modified a record and when. This is required for compliance (GDPR, SOX), debugging production issues, and building audit trails. Writing these fields manually on every entity is tedious and easy to forget.
Solution
Section titled “Solution”Add [Auditable] to your entity. The source generator adds four properties and the IAuditable interface:
// ═══ What YOU write ═══[Entity<Guid>][Auditable]public partial class Order{ public string OrderNumber { get; private set; } = "";}// ═══ What the SG generates ═══public partial class Order : IAuditable{ public DateTimeOffset CreatedAt { get; set; } public string? CreatedBy { get; set; } public DateTimeOffset? UpdatedAt { get; set; } public string? UpdatedBy { get; set; }}How the values are set
Section titled “How the values are set”The audit fields are not set by your code — they are populated automatically by the AuditingInterceptor in Pragmatic.Persistence.EFCore:
| When | CreatedAt | CreatedBy | UpdatedAt | UpdatedBy |
|---|---|---|---|---|
| Entity is first saved | Set to now | Set from ICurrentUser | Set to now | Set from ICurrentUser |
| Entity is updated | Preserved | Preserved | Updated to now | Updated from ICurrentUser |
If ICurrentUser is not registered in DI, the *By fields remain null.
[SoftDelete]
Section titled “[SoftDelete]”Problem
Section titled “Problem”Deleting data from a database is irreversible. In many applications, you need the ability to “delete” records while keeping them in the database — for recovery, compliance, or referential integrity.
Hard delete: DELETE FROM Orders WHERE Id = @id — data is gone forever.
Soft delete: UPDATE Orders SET IsDeleted = 1 WHERE Id = @id — data is hidden but recoverable.
Solution
Section titled “Solution”Add [SoftDelete] to your entity:
// ═══ What YOU write ═══[Entity<Guid>][SoftDelete]public partial class Customer{ public string Name { get; private set; } = "";}// ═══ What the SG generates ═══public partial class Customer : ISoftDelete{ public bool IsDeleted { get; set; } public DateTimeOffset? DeletedAt { get; set; } public string? DeletedBy { get; set; }}What happens automatically
Section titled “What happens automatically”-
Query filtering — A
SoftDeleteFilter_Customeris generated and registered. All queries automatically exclude records whereIsDeleted == true. You never see deleted records unless you explicitly opt out. -
Repository
Remove()— When you callrepository.Remove(customer), it performs a soft-delete (setsIsDeleted = true) instead of a physical DELETE. -
Cascade (optional) — With
[SoftDelete(Cascade = true)], soft-deleting a parent also soft-deletes its children:
[Entity<Guid>][SoftDelete(Cascade = true)] // Deleting a Property also soft-deletes its RoomTypes[Relation.OneToMany<RoomType>]public partial class Property { /* ... */ }Bypassing the filter
Section titled “Bypassing the filter”Sometimes you need to see deleted records (e.g., an admin “recycle bin”):
using (filterToggle.Disable<SoftDeleteFilter_Customer>()){ var allCustomers = await repo.Query().ToListAsync(ct); // Includes deleted}See Query Filters for full details on the filter toggle mechanism.
[ConcurrencyAware]
Section titled “[ConcurrencyAware]”Problem
Section titled “Problem”Two users open the same entity, edit it, and save. Without concurrency control, the second save silently overwrites the first user’s changes. This is called the lost update problem.
Solution
Section titled “Solution”Optimistic concurrency: each entity has a RowVersion that the database increments on every update. If you try to save with an outdated version, EF Core throws DbUpdateConcurrencyException.
// ═══ What YOU write ═══[Entity<Guid>][ConcurrencyAware]public partial class Invoice { /* ... */ }// ═══ What the SG generates ═══public partial class Invoice : IConcurrencyAware{ public byte[] RowVersion { get; set; } = [];}The generated EF Core configuration marks RowVersion as a concurrency token:
// ═══ In InvoiceEntityConfiguration.g.cs ═══builder.Property(e => e.RowVersion).IsRowVersion();How it works at runtime
Section titled “How it works at runtime”- User A loads Invoice (RowVersion =
0x0001) - User B loads Invoice (RowVersion =
0x0001) - User A saves → Success, RowVersion becomes
0x0002 - User B saves → Fails with
DbUpdateConcurrencyExceptionbecause their RowVersion (0x0001) doesn’t match the database (0x0002)
Your application catches the exception and asks User B to reload and retry.
[BelongsTo<TBoundary>]
Section titled “[BelongsTo<TBoundary>]”Problem
Section titled “Problem”Large applications have many entities. Putting them all in one DbContext becomes unwieldy — slow model building, confusing navigation properties across unrelated domains.
Solution
Section titled “Solution”Boundaries partition entities into logical groups. Each boundary maps to a DbContext.
// Define boundaries as empty marker classespublic class BillingBoundary;public class BookingBoundary;
// Assign entities[Entity<Guid>][BelongsTo<BillingBoundary>]public partial class Invoice { /* ... */ }
[Entity<Guid>][BelongsTo<BookingBoundary>]public partial class Reservation { /* ... */ }When you configure [Persist<BillingBoundary>] on a DbContext, only entities belonging to that boundary are included.
See DbContext Generation for how boundaries connect to DbContexts.
[Lookup]
Section titled “[Lookup]”Problem
Section titled “Problem”Some entities are reference data that rarely changes — countries, categories, statuses. Querying the database for these on every request wastes resources.
Solution
Section titled “Solution”[Lookup] marks an entity as a lookup table. At application startup, all records are loaded into an in-memory cache. Subsequent access is synchronous and instant — zero database queries.
// ═══ What YOU write ═══[Entity<Guid>][Lookup][BelongsTo<CatalogBoundary>]public partial class Category{ public string Name { get; private set; } = "";}// ═══ What the SG generates ═══
// CategoryLookupCacheLoader.g.cs — Loads all categories from DB at startup// LookupCacheRegistrationExtensions.g.cs — DI registration for cache + loader + hosted servicepublic class ProductService(ILookupCache<Category, Guid> categories){ public Category GetCategory(Guid id) { return categories.Get(id); // Synchronous, zero DB query }
public IReadOnlyList<Category> GetAll() { return categories.GetAll(); // All categories, from memory }}When to use
Section titled “When to use”- Reference data that changes infrequently (countries, currencies, categories)
- Data accessed on almost every request (roles, statuses)
When NOT to use
Section titled “When NOT to use”- Data that changes frequently (you’d need cache invalidation)
- Large datasets (thousands of records in memory)
[StateMachine<TEnum>]
Section titled “[StateMachine<TEnum>]”The Problem
Section titled “The Problem”Entities with status fields (Pending → Approved → Shipped → Delivered) need transition validation. Without it, any code can set order.Status = Delivered directly — skipping required steps and violating business rules.
The Solution
Section titled “The Solution”Define allowed transitions on the enum itself:
public enum OrderStatus{ [InitialState] Draft,
[TransitionFrom(nameof(Draft))] Pending,
[TransitionFrom(nameof(Pending))] [RaisesEvent<OrderApprovedEvent>] Approved,
[TransitionFrom(nameof(Approved))] Shipped,
[TransitionFrom(nameof(Shipped))] [RaisesEvent<OrderDeliveredEvent>] Delivered,
[TransitionFrom(nameof(Pending))] [TransitionFrom(nameof(Approved))] Cancelled}Apply it to the entity:
[Entity<Guid>][StateMachine<OrderStatus>]public partial class Order{ // Status property is auto-generated}What the Source Generator Produces
Section titled “What the Source Generator Produces”public partial class Order{ public OrderStatus Status { get; private set; }
public Result<Order, TransitionError> TransitionTo(OrderStatus target) { if (!CanTransitionTo(target)) return new TransitionError(Status, target, AllowedTransitions());
Status = target;
// Events declared via [RaisesEvent<T>] if (target == OrderStatus.Approved) AddDomainEvent(new OrderApprovedEvent(PersistenceId)); if (target == OrderStatus.Delivered) AddDomainEvent(new OrderDeliveredEvent(PersistenceId));
return this; }
public bool CanTransitionTo(OrderStatus target) { return (Status, target) switch { (OrderStatus.Draft, OrderStatus.Pending) => true, (OrderStatus.Pending, OrderStatus.Approved) => true, (OrderStatus.Approved, OrderStatus.Shipped) => true, (OrderStatus.Shipped, OrderStatus.Delivered) => true, (OrderStatus.Pending, OrderStatus.Cancelled) => true, (OrderStatus.Approved, OrderStatus.Cancelled) => true, _ => false }; }
public IReadOnlyList<OrderStatus> AllowedTransitions() { return Status switch { OrderStatus.Draft => [OrderStatus.Pending], OrderStatus.Pending => [OrderStatus.Approved, OrderStatus.Cancelled], OrderStatus.Approved => [OrderStatus.Shipped, OrderStatus.Cancelled], OrderStatus.Shipped => [OrderStatus.Delivered], _ => [] }; }}Key Attributes
Section titled “Key Attributes”| Attribute | Target | Purpose |
|---|---|---|
[StateMachine<TEnum>] | Entity class | Enables state machine with generated Status property |
[InitialState] | Enum value | Marks the starting state (exactly one required) |
[TransitionFrom(name)] | Enum value | Declares a legal source state (AllowMultiple) |
[RaisesEvent<TEvent>] | Enum value | Raises domain event on successful transition (AllowMultiple) |
Multiple State Machines
Section titled “Multiple State Machines”An entity can have multiple independent state machines:
[Entity<Guid>][StateMachine<PaymentStatus>(Property = "PaymentStatus")][StateMachine<FulfillmentStatus>(Property = "FulfillmentStatus")]public partial class Order { }The Property parameter specifies which property to generate. Each state machine gets its own TransitionTo{PropertyName}() method.
var order = Order.Create("ORD-001", 199.99m, customerId);// order.Status == OrderStatus.Draft (InitialState)
var result = order.TransitionTo(OrderStatus.Pending);// result.IsSuccess == true
var invalid = order.TransitionTo(OrderStatus.Delivered);// invalid.IsSuccess == false// invalid.Error == TransitionError { From: Pending, To: Delivered }TransitionTo() returns Result<T, TransitionError> — it never throws exceptions. Use pattern matching to handle the result:
var result = order.TransitionTo(OrderStatus.Approved);return result.Match( success: o => Results.Ok(o), failure: e => Results.BadRequest($"Cannot transition from {e.From} to {e.To}"));