Skip to content

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.


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.

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

The audit fields are not set by your code — they are populated automatically by the AuditingInterceptor in Pragmatic.Persistence.EFCore:

WhenCreatedAtCreatedByUpdatedAtUpdatedBy
Entity is first savedSet to nowSet from ICurrentUserSet to nowSet from ICurrentUser
Entity is updatedPreservedPreservedUpdated to nowUpdated from ICurrentUser

If ICurrentUser is not registered in DI, the *By fields remain null.


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.

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; }
}
  1. Query filtering — A SoftDeleteFilter_Customer is generated and registered. All queries automatically exclude records where IsDeleted == true. You never see deleted records unless you explicitly opt out.

  2. Repository Remove() — When you call repository.Remove(customer), it performs a soft-delete (sets IsDeleted = true) instead of a physical DELETE.

  3. 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 { /* ... */ }

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.


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.

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();
  1. User A loads Invoice (RowVersion = 0x0001)
  2. User B loads Invoice (RowVersion = 0x0001)
  3. User A saves → Success, RowVersion becomes 0x0002
  4. User B saves → Fails with DbUpdateConcurrencyException because their RowVersion (0x0001) doesn’t match the database (0x0002)

Your application catches the exception and asks User B to reload and retry.


Large applications have many entities. Putting them all in one DbContext becomes unwieldy — slow model building, confusing navigation properties across unrelated domains.

Boundaries partition entities into logical groups. Each boundary maps to a DbContext.

// Define boundaries as empty marker classes
public 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.


Some entities are reference data that rarely changes — countries, categories, statuses. Querying the database for these on every request wastes resources.

[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 service
public 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
}
}
  • Reference data that changes infrequently (countries, currencies, categories)
  • Data accessed on almost every request (roles, statuses)
  • Data that changes frequently (you’d need cache invalidation)
  • Large datasets (thousands of records in memory)

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.

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
}
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],
_ => []
};
}
}
AttributeTargetPurpose
[StateMachine<TEnum>]Entity classEnables state machine with generated Status property
[InitialState]Enum valueMarks the starting state (exactly one required)
[TransitionFrom(name)]Enum valueDeclares a legal source state (AllowMultiple)
[RaisesEvent<TEvent>]Enum valueRaises domain event on successful transition (AllowMultiple)

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