Architecture and Core Concepts
This guide explains why Pragmatic.Persistence exists, how its pieces fit together, and how to choose the right abstraction for each situation. Read this before diving into the individual feature guides.
The Problem
Section titled “The Problem”EF Core is a capable ORM. But every project that uses it accumulates the same boilerplate: entity identity, audit trails, soft-delete fields, repository patterns, FK configuration, query filters, migration contexts, DI wiring. The effort is not in any single line of code — it is in the hundreds of lines that repeat identically across every entity in the system.
A typical entity: manual everything
Section titled “A typical entity: manual everything”// Entity — you write every property, every interface, every factorypublic class Order : IEntity<Guid>, IAuditable, ISoftDelete{ public Guid Id { get; set; } public string OrderNumber { get; private set; } = ""; public decimal Total { get; private set; }
// Audit fields — repeated on every auditable entity public DateTimeOffset CreatedAt { get; set; } public string? CreatedBy { get; set; } public DateTimeOffset? UpdatedAt { get; set; } public string? UpdatedBy { get; set; }
// Soft-delete fields — repeated on every soft-deletable entity public bool IsDeleted { get; set; } public DateTimeOffset? DeletedAt { get; set; } public string? DeletedBy { get; set; }
// Navigation properties — manual FK + nav public Guid CustomerId { get; private set; } public Customer? Customer { get; set; } public ICollection<LineItem> Items { get; } = [];
// Factory method — hand-written public static Order Create(string orderNumber, decimal total, Guid customerId) { return new Order { Id = Guid.NewGuid(), OrderNumber = orderNumber, Total = total, CustomerId = customerId, CreatedAt = DateTimeOffset.UtcNow }; }
// Setters — hand-written per property public void SetTotal(decimal value) => Total = value; public void SetOrderNumber(string value) => OrderNumber = value;
// Equality — repeated on every entity public override bool Equals(object? obj) => obj is Order o && o.Id == Id; public override int GetHashCode() => Id.GetHashCode();}// Repository — every entity gets onepublic class OrderRepository : IRepository<Order, Guid>{ private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct) => _db.Orders.FirstOrDefaultAsync(o => o.Id == id, ct);
public Task<List<Order>> FindAsync(Expression<Func<Order, bool>> predicate, CancellationToken ct) => _db.Orders.Where(predicate).ToListAsync(ct);
public void Add(Order entity) => _db.Orders.Add(entity); public void Remove(Order entity) => _db.Orders.Remove(entity); public void Update(Order entity) => _db.Entry(entity).State = EntityState.Modified;
// Logic key lookup — hand-written public Task<Order?> GetByOrderNumberAsync(string number, CancellationToken ct) => _db.Orders.FirstOrDefaultAsync(o => o.OrderNumber == number, ct);}// EF Core configuration — per entitypublic class OrderConfiguration : IEntityTypeConfiguration<Order>{ public void Configure(EntityTypeBuilder<Order> builder) { builder.HasKey(e => e.Id); builder.HasIndex(e => e.OrderNumber).IsUnique(); builder.HasOne(e => e.Customer).WithMany().HasForeignKey(e => e.CustomerId); builder.HasMany(e => e.Items).WithOne().HasForeignKey(e => e.OrderId); builder.HasQueryFilter(e => !e.IsDeleted); builder.Property(e => e.OrderNumber).HasMaxLength(50); }}// Query — manual filter/sort/pagepublic async Task<PagedResult<OrderDto>> SearchOrders( string? customerName, OrderStatus? status, SortDirection? dateSort, int page = 1, int pageSize = 20){ var query = _db.Orders.AsNoTracking().Where(o => !o.IsDeleted);
if (customerName is not null) query = query.Where(o => o.Customer!.Name.Contains(customerName)); if (status is not null) query = query.Where(o => o.Status == status);
query = dateSort == SortDirection.Ascending ? query.OrderBy(o => o.CreatedAt) : query.OrderByDescending(o => o.CreatedAt);
var total = await query.CountAsync(); var items = await query .Skip((page - 1) * pageSize).Take(pageSize) .Select(o => new OrderDto { /* map 15 properties */ }) .ToListAsync();
return new PagedResult<OrderDto>(items, total, page, pageSize);}// DI registration — per entity, per boundaryservices.AddScoped<IRepository<Order, Guid>, OrderRepository>();services.AddScoped<IRepository<LineItem, Guid>, LineItemRepository>();services.AddScoped<IRepository<Customer, Guid>, CustomerRepository>();// ... for every entityFor a single entity with auditing, soft-delete, a relationship, a logic key, and a search query, that is roughly 150 lines of infrastructure code. For a project with 30 entities, that is 4,500 lines of mechanical, error-prone code that you write, maintain, and keep in sync. Every new property means updating the entity, the setter, the Create factory, the configuration, the DTO mapping, and possibly the query filter.
The fundamental issue: the framework has enough information from your entity declaration to generate all of this. Attributes describe intent. The rest is derivable.
The Solution
Section titled “The Solution”Pragmatic.Persistence inverts the model. You declare the entity’s shape and intent with attributes. The source generator reads those declarations at compile time and produces every artifact: identity properties, audit fields, soft-delete fields, the Create factory, typed setters, the repository class, EF Core entity configuration, query filters, DI registration, and mutation pipelines.
The same Order entity:
[Entity<Guid>][Auditable][SoftDelete][BelongsTo<SalesBoundary>][Relation.OneToMany<LineItem>][Relation.ManyToOne<Customer>]public partial class Order{ [LogicKey] public string OrderNumber { get; private set; } = "";
public decimal Total { get; private set; }}The same query:
[Query<Order, OrderDto>][Endpoint(HttpVerb.Get, "api/v1/orders/search")]public partial class SearchOrdersQuery{ [Filter(Operator = FilterOperator.Contains, MapTo = "Customer.Name")] public string? CustomerName { get; init; }
[Filter] public OrderStatus? Status { get; init; }
[Sort(DefaultDirection = 1)] public SortDirection? CreatedAtSort { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}The source generator produces:
PersistenceId,Id, equality, interface implementationCreatedAt,CreatedBy,UpdatedAt,UpdatedBy(from[Auditable])IsDeleted,DeletedAt,DeletedBy(from[SoftDelete])static Create(...)factory with all required propertiesSetOrderNumber(string),SetTotal(decimal)typed settersCustomerIdFK property,Customernavigation,Itemscollection (from[Relation.*])Order.Repositorynested class implementingIRepository<Order, Guid>withGetByOrderNumberAsyncEntityConfig.Order.g.cswith key, indexes, relationships, query filterOrder.SoftDeleteFilterimplementingIQueryFilter<Order>SearchOrdersQuery.Apply()with generated WHERE + ORDER BY- DI registration for repository, DbContext, query filters
150 lines of infrastructure code reduced to 20 lines of declarations. No reflection at runtime. The generated code is visible under obj/ in your IDE, fully debuggable.
How It Works: Entity Lifecycle
Section titled “How It Works: Entity Lifecycle”Every entity in Pragmatic goes through a deterministic lifecycle, from declaration to database. The source generator assembles the lifecycle at compile time based on the attributes you choose.
Your Entity Class (attributes) | vPragmatic.SourceGenerator (compile time) | +---> Entity Members PersistenceId, Id, Create(), SetXxx(), equality +---> Trait Properties IAuditable, ISoftDelete, IConcurrencyAware +---> Relationship Props FK properties, navigation properties +---> Repository Class Nested Entity.Repository : IRepository<T, TId> +---> Entity Config IEntityTypeConfiguration<T> for EF Core +---> Query Filters SoftDeleteFilter, TenantFilter, TemporalFilter +---> DI Registration Extension methods for service collection | vPragmatic.Persistence.EFCore (runtime) | +---> DbContext Boundary-scoped, with generated DbSets +---> Interceptors AuditingInterceptor, SoftDeleteInterceptor +---> Query Executor EfCoreQueryExecutor with filter pipeline +---> Filter Pipeline Root + navigation filtering | vDatabase (SQL)The lifecycle steps that apply depend on the attributes present on the entity. An entity with only [Entity<Guid>] gets the minimum: identity, factory, setters, repository. Add [Auditable] and it gains four audit fields plus the interceptor wiring. Add [SoftDelete] and it gains three soft-delete fields, a query filter, and modified Remove behavior. The SG only generates what is declared — there is no performance penalty for unused features.
Entity System
Section titled “Entity System”The entity system is the foundation. Everything else — repositories, queries, mutations, filters — builds on entity declarations.
Core Declaration
Section titled “Core Declaration”Every entity starts with [Entity<TId>] on a partial class:
[Entity<Guid>][BelongsTo<SalesBoundary>]public partial class Order{ public string OrderNumber { get; private set; } = ""; public decimal Total { get; private set; }}The class must be partial because the SG emits additional members in a separate .g.cs file. Without partial, the compiler cannot merge them, and diagnostic PRAG0600 fires.
ID Types
Section titled “ID Types”| Type | Generation Strategy | When to Use |
|---|---|---|
Guid | Guid7 / UUID v7 (time-ordered) | Default for most entities. Works in distributed systems. |
int | Database auto-increment | When the database owns ID creation. |
long | Database auto-increment | Same as int with more headroom. |
string | Must be set explicitly | When the string itself is the real identifier. |
For most projects, Guid is the safest default. Guid7 produces time-ordered values that are index-friendly and do not require a database round-trip to generate.
Generated Members
Section titled “Generated Members”From [Entity<Guid>] alone, the SG produces:
| Member | Purpose |
|---|---|
Guid PersistenceId { get; set; } | The primary key. |
Guid Id => PersistenceId | Typed alias for convenience. |
static Create(...) | Factory method with parameters for all settable properties. |
SetXxx(value) | Typed setter for each private set property. |
Equals, GetHashCode, ==, != | Identity-based equality. |
Entity Traits
Section titled “Entity Traits”Traits are attributes that add cross-cutting fields and behavior to entities. Each trait adds properties, an interface implementation, and infrastructure hooks.
| Trait | Interface | Added Properties | Runtime Behavior |
|---|---|---|---|
[Auditable] | IAuditable | CreatedAt, CreatedBy, UpdatedAt, UpdatedBy | AuditingInterceptor sets values on save |
[SoftDelete] | ISoftDelete | IsDeleted, DeletedAt, DeletedBy | Remove() sets fields instead of DELETE. Query filter hides deleted rows. |
[SoftDelete(Cascade = true)] | ISoftDelete | Same as above | Soft-deleting parent cascades to children |
[ConcurrencyAware] | IConcurrencyAware | byte[] RowVersion | EF Core concurrency token. DbUpdateConcurrencyException on stale writes. |
Traits compose freely. An entity can have all of them:
[Entity<Guid>][Auditable][SoftDelete][ConcurrencyAware][BelongsTo<BillingBoundary>]public partial class Invoice{ public decimal Total { get; private set; }}PersistenceId vs Business Keys
Section titled “PersistenceId vs Business Keys”Pragmatic separates the technical identifier (PersistenceId) from human-facing business keys. Use [LogicKey] for business identifiers:
[Entity<Guid>]public partial class Product{ [LogicKey] public string Sku { get; private set; } = ""; // Business key — unique index generated public string Name { get; private set; } = "";}[LogicKey] generates a unique index in EF Core configuration and a GetBySkuAsync() method on the concrete repository. [AlternateKey("INV-{YYYY}{MM}-{SEQ:5}")] generates formatted business keys with date and sequence placeholders.
State Machines
Section titled “State Machines”Entities with status fields use [StateMachine<TEnum>] for validated transitions:
public enum OrderStatus{ [InitialState] Draft, [TransitionFrom(nameof(Draft))] Pending, [TransitionFrom(nameof(Pending))] Approved, [TransitionFrom(nameof(Approved))] Shipped, [TransitionFrom(nameof(Shipped))] Delivered, [TransitionFrom(nameof(Pending)), TransitionFrom(nameof(Approved))] Cancelled}
[Entity<Guid>][StateMachine<OrderStatus>]public partial class Order { /* Status property generated */ }The SG generates TransitionTo(target) returning Result<Order, TransitionError>, CanTransitionTo(target), and AllowedTransitions(). Invalid transitions never throw — they return a typed error.
Relationships
Section titled “Relationships”All relationships are declared via [Relation.*] attributes on the entity class. The SG generates FK properties, navigation properties, and EF Core configuration.
Relationship Types
Section titled “Relationship Types”// One-to-many: parent side[Relation.OneToMany<LineItem>]
// Many-to-one: child side[Relation.ManyToOne<Customer>]
// One-to-one[Relation.OneToOne<UserProfile>]
// Many-to-many with explicit join entity[Relation.ManyToMany<Tag>(JoinEntity = typeof(OrderTag))]
// Explicit navigation names[Relation.ManyToOne<User>.WithNavigation("CreatedBy", Inverse = "CreatedOrders")]What Gets Generated
Section titled “What Gets Generated”For [Relation.OneToMany<LineItem>] on Order:
- On
Order:public ICollection<LineItem> Items { get; } = []; - On
LineItem:public Guid OrderId { get; private set; }(FK) - EF Core config:
HasMany/WithOne/HasForeignKey/OnDelete(NoAction)
Cross-Boundary Relationships
Section titled “Cross-Boundary Relationships”Entities from different boundaries can reference each other, but with limitations. The FK property is generated and stored. Navigation properties are not generated because the entities live in different DbContexts. Include() across boundaries does not work. Load related data separately instead.
Diagnostic PRAG0617 (Info) informs you when a relation crosses boundaries.
Delete Behavior
Section titled “Delete Behavior”All generated relationships use OnDelete(DeleteBehavior.NoAction) by default. This is intentional — cascade deletes in the database are dangerous and hard to debug. Use [SoftDelete(Cascade = true)] for application-level cascading.
Boundaries and DbContext
Section titled “Boundaries and DbContext”A boundary is a marker class that groups entities into a logical domain partition. Each boundary maps to a DbContext, a keyed IUnitOfWork, and a set of repositories.
// Define boundariespublic class BillingBoundary;public class BookingBoundary;
// Assign entities[Entity<Guid>][BelongsTo<BillingBoundary>]public partial class Invoice { /* ... */ }
[Entity<Guid>][BelongsTo<BookingBoundary>]public partial class Reservation { /* ... */ }Why Boundaries Exist
Section titled “Why Boundaries Exist”| Without Boundaries | With Boundaries |
|---|---|
| One giant DbContext with all entities | Focused DbContexts per domain (10-30 entities each) |
| Slow model building at startup | Fast model building |
| No isolation between domains | Clear ownership and access control |
| All entities share one connection string | Different boundaries can use different databases |
| One transaction for everything | Boundary = unit of transactional consistency |
DbContext Generation
Section titled “DbContext Generation”The host project declares the database provider:
[Database<SQLite>("App", ConnectionString = "App")][PersistAll]public partial class AppDatabase;Or for multi-database setups:
[Database<PostgreSQL>("Sales")][Persist<SalesBoundary>]public partial class SalesDatabase;The SG generates a BoundaryDbContext per boundary with only the entities in that boundary, registered with keyed DI.
UnitOfWork
Section titled “UnitOfWork”IUnitOfWork is registered as a keyed service by boundary type:
var uow = services.GetRequiredKeyedService<IUnitOfWork>(typeof(BillingBoundary));await uow.SaveChangesAsync(ct);Transactions are explicit:
await using var tx = await uow.BeginTransactionAsync(ct);try{ orders.Add(order); await uow.SaveChangesAsync(ct); await tx.CommitAsync(ct);}catch{ await tx.RollbackAsync(ct); throw;}Repository Pattern
Section titled “Repository Pattern”The SG generates a concrete repository class for each entity as a nested class inside the entity’s partial class. This keeps the repository co-located with its entity.
Two API Layers
Section titled “Two API Layers”There are two repository surfaces:
1. Stable abstractions — the interfaces you should prefer for application code:
public interface IReadRepository<TEntity, TId>{ Task<TEntity?> GetByIdAsync(TId id, CancellationToken ct = default); Task<List<TEntity>> FindAsync(Specification<TEntity> spec, CancellationToken ct = default); Task<int> CountAsync(Specification<TEntity> spec, CancellationToken ct = default); Task<bool> ExistsAsync(Specification<TEntity> spec, CancellationToken ct = default); Task<TEntity?> FirstOrDefaultAsync(Specification<TEntity> spec, CancellationToken ct = default); IQueryable<TEntity> Query();}
public interface IRepository<TEntity, TId> : IReadRepository<TEntity, TId>{ void Add(TEntity entity); void AddRange(IEnumerable<TEntity> entities); void Remove(TEntity entity); void RemoveRange(IEnumerable<TEntity> entities); void Update(TEntity entity);}2. Generated concrete repository — exposes additional convenience members:
public partial class Order{ public sealed class Repository : IRepository<Order, Guid> { // All IRepository methods...
// Logic key lookup (from [LogicKey]) public Task<Order?> GetByOrderNumberAsync(string key, CancellationToken ct = default);
// Include overload public Task<Order?> GetByIdAsync( Guid id, Func<IQueryable<Order>, IQueryable<Order>> includes, CancellationToken ct = default);
// Bulk operations public Task BulkInsertAsync(IEnumerable<Order> entities, CancellationToken ct = default); }}Which One to Inject?
Section titled “Which One to Inject?”| Need | Inject |
|---|---|
| Stable CRUD / specification access | IRepository<Order, Guid> |
| Read-only stable access | IReadRepository<Order, Guid> |
| Logic key helper or include overload | Order.Repository (concrete) |
| Bulk operations | Order.Repository (concrete) |
Specifications
Section titled “Specifications”Repositories accept Specification<T> for reusable, composable predicates:
var largeOrders = Spec<Order>.Where(o => o.Total > 1000);var pendingLarge = largeOrders & Spec<Order>.Where(o => o.Status == OrderStatus.Pending);
var count = await orders.CountAsync(largeOrders, ct);var list = await orders.FindAsync(pendingLarge, ct);Specifications support & (AND), | (OR), ! (NOT), and conditional composition with AndIf / OrIf.
Automatic Filtering
Section titled “Automatic Filtering”Every generated read path applies root filters automatically through IQueryFilterProvider. Soft-deleted rows are excluded, tenant data is isolated, and temporal filters are applied — all without you writing Where(!IsDeleted) on every query.
Query System
Section titled “Query System”The query system lets you declare what to filter, sort, and project — the SG produces the implementation.
Declarative Queries
Section titled “Declarative Queries”[Query<Order, OrderDto>]public partial class SearchOrders{ [Filter(Operator = FilterOperator.Contains)] public string? CustomerName { get; init; }
[Filter] public OrderStatus? Status { get; init; }
[Sort(DefaultDirection = 1)] // Descending by default public SortDirection? CreatedAtSort { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}The SG generates:
IPagedQuery<Order, OrderDto>implementationApply(IQueryable<Order>)with WHERE + ORDER BYProjectionexpression from[MapFrom<Order>]on the DTO
Filter Operators
Section titled “Filter Operators”| Operator | SQL Equivalent | Typical Use |
|---|---|---|
Equals | = value | Exact match (ID, status, boolean) |
NotEquals | != value | Exclusion filter |
Contains | LIKE '%value%' | Text search |
StartsWith | LIKE 'value%' | Prefix search |
GreaterThan / GreaterOrEqual | > / >= | Range (dates, amounts) |
LessThan / LessOrEqual | < / <= | Range |
In | IN (values) | Multi-select (statuses, categories) |
Between | >= min AND <= max | Date ranges, price ranges |
Filter DTOs
Section titled “Filter DTOs”Reusable filter logic without a full query:
[FilterDto<Order>]public partial class OrderFilter{ [Filter] public OrderStatus? Status { get; init; }
[Filter(Operator = FilterOperator.GreaterOrEqual, MapTo = "Total")] public decimal? MinTotal { get; init; }}
// Usage: var pending = await db.Orders.ApplyFilter(filter).ToListAsync(ct);Query Execution Pipeline
Section titled “Query Execution Pipeline”HTTP GET /api/orders/search?Status=Pending&Page=2 | v1. Model binding --> Query object2. IQueryExecutor.ExecuteAsync()3. Prepare source (NoTracking, SplitQuery, IgnoreGlobalFilters)4. Apply global filters (soft-delete, tenant, permission)5. query.Apply() -- YOUR generated WHERE + ORDER BY6. COUNT(*), SKIP/TAKE, SELECT projection7. Return PagedResult<TResult> | vHTTP 200 { items: [...], totalCount: 47, page: 2, totalPages: 3 }All filters — root, navigation, and query — compose into a single expression tree before EF Core translates to SQL. There is no in-memory filtering.
Query Filters
Section titled “Query Filters”Query filters are safety rails that enforce data visibility rules. You define them once; they apply automatically on every generated read path.
Auto-Generated Filters
Section titled “Auto-Generated Filters”| Source | Effect |
|---|---|
[SoftDelete] on entity | Hides rows where IsDeleted == true |
Tenant-aware entity (implements ITenantEntity) | Restricts rows to TenantId == currentTenant |
[TemporalRelation] on entity | Hides inactive historical rows |
Filter Modes
Section titled “Filter Modes”| Mode | Soft-Delete | Tenant | Visibility | Permission |
|---|---|---|---|---|
| Normal | Applied | Applied | Applied | Applied |
| Admin | Applied | Applied | Skipped | Skipped |
| Background | Applied | Skipped | Skipped | Skipped |
| Raw | Skipped | Skipped | Skipped | Skipped |
Bypassing Filters
Section titled “Bypassing Filters”Use IQueryFilterToggle for scoped overrides:
// Disable a specific filterusing (filterToggle.Disable<SoftDeleteFilter_Order>()){ var allOrders = await orders.Query().ToListAsync(ct); // Includes deleted}
// Switch to admin modeusing (filterToggle.UseMode(FilterMode.Admin)){ // Skip visibility and permission filters}The override is temporary — AsyncLocal-scoped, restored automatically on dispose.
Mutations
Section titled “Mutations”Mutations are operations that modify a single entity through the MutationInvoker pipeline. A mutation class inherits from Mutation<TEntity> and declares properties to map.
Mutation Modes
Section titled “Mutation Modes”| Mode | Behavior |
|---|---|
Create | Creates a new entity via Create() factory, applies properties, saves |
Update | Loads entity by ID, applies non-null properties (partial update), saves |
Delete | Loads entity, marks deleted (soft-delete if [SoftDelete]), saves |
Restore | Loads bypassing all filters, resets IsDeleted/DeletedAt/DeletedBy, saves |
The Mutation Pipeline
Section titled “The Mutation Pipeline”HTTP POST /api/v1/invoices | v1. Endpoint handler (model binding, pre-processors)2. MutationInvoker.InvokeAsync(): a. Inject dependencies b. L1 Validation: sync (attribute-based) + async (DB-aware) c. Load or create entity d. Apply computed defaults (if create) e. mutation.ApplyAsync(entity) -- generated property mapping f. L2 Validation: entity invariants (after apply) g. Persist (repository.Add + UnitOfWork.SaveChanges) h. Dispatch domain events i. Invalidate cache | vResult<TEntity, IError> | vHTTP 201 Created / 400 / 404Why Two Validation Levels?
Section titled “Why Two Validation Levels?”| Level | Target | When | What It Catches |
|---|---|---|---|
| L1 | Mutation DTO | Before entity load | Invalid input (missing fields, bad format, non-existent references) |
| L2 | Entity | After apply | Business invariants (negative balance, invalid state transition) |
L1 is fast and prevents unnecessary database work. L2 catches violations that only become apparent after the mutation is applied to the entity.
Collection Strategies
Section titled “Collection Strategies”When a mutation includes a collection property, you choose how to update:
| Strategy | Behavior |
|---|---|
Replace | Clear existing, add all from DTO |
Merge | Match by ID: update existing, add new, remove missing |
Append | Add all from DTO, keep existing |
[Mutation]public partial class UpdateOrderDto{ [CollectionStrategy(CollectionMutationStrategy.Merge)] public List<UpdateLineDto>? Lines { get; init; }}What Gets Generated
Section titled “What Gets Generated”For each entity marked with [Entity<TId>], the SG produces files depending on the attributes present. Here is the complete table:
| Generated File | Content | Condition |
|---|---|---|
{Entity}.g.cs | PersistenceId, Id, interface implementation | Always |
{Entity}.Create.g.cs | Static Create(...) factory | Non-abstract entities |
{Entity}.Setters.g.cs | Typed SetXxx() for each private set property | Has settable properties |
{Entity}.Auditable.g.cs | CreatedAt, CreatedBy, UpdatedAt, UpdatedBy | [Auditable] |
{Entity}.SoftDelete.g.cs | IsDeleted, DeletedAt, DeletedBy | [SoftDelete] |
{Entity}.Relations.g.cs | FK properties, navigation properties | [Relation.*] |
{Entity}.Repository.g.cs | Nested Entity.Repository class | Has [BelongsTo] + EFCore ref |
{Entity}.SoftDeleteFilter.g.cs | Nested SoftDeleteFilter query filter | [SoftDelete] |
{Entity}.TenantFilter.g.cs | Nested TenantFilter query filter | Implements ITenantEntity |
{Entity}.StateMachine.g.cs | Status, TransitionTo(), CanTransitionTo() | [StateMachine<TEnum>] |
{Entity}.Projectable.g.cs | Expression fields for computed properties | [Projectable] / [ComputedFilter] |
EntityConfig.{Entity}.g.cs | EF Core IEntityTypeConfiguration (host-level) | Has [BelongsTo] + EFCore ref |
_Infra.Persistence.Registration.g.cs | DI registration for DbContext, repos, filters | Once per assembly |
DbContext.{Boundary}.g.cs | Boundary DbContext with DbSets | Per boundary |
All generated files live under obj/Debug/net10.0/generated/ and are fully visible in the IDE. You can set breakpoints in generated code.
Choosing the Right Pattern
Section titled “Choosing the Right Pattern”When to Use What
Section titled “When to Use What”| Scenario | Pattern | Why |
|---|---|---|
| Simple read by ID | IReadRepository<T, TId>.GetByIdAsync() | Direct, no overhead |
| Read by business key | Entity.Repository.GetByXxxAsync() | Concrete repo has the helper |
| Paginated search with filters | [Query<T, R>] with [Filter] / [Sort] | SG generates the pipeline |
| Grid with dynamic operators | [GridFilter<T>] with [Filterable] | Runtime operator selection |
| External grid framework (DevExpress, PrimeNG) | QueryBuilder.FromDevExpress() / FromPrimeNG() | Runtime adapter |
| Reusable filter logic | [FilterDto<T>] | Shared across queries |
| Create entity from DTO | Mutation<T> with MutationMode.Create | Generated load-apply-save |
| Update entity (partial) | Mutation<T> with MutationMode.Update | Nullable props = skip unchanged |
| Delete (soft) | Mutation<T> with MutationMode.Delete | Auto soft-delete for [SoftDelete] entities |
| Complex business operation | DomainAction<T> | Full control via Execute() |
| Computed property in SQL | [Projectable] | Expression for EF Core translation |
| Reusable boolean filter | [ComputedFilter] | Expression + Specification + extension |
| Aggregation report | [QueryView<T>] | Declarative GROUP BY + SUM/COUNT |
Projection vs Entity Loading
Section titled “Projection vs Entity Loading”| Approach | Tracking | Performance | Use When |
|---|---|---|---|
[Query<T, R>] (with projection) | No | Best | Read-only endpoints, API responses |
[Query<T>] (entity) | Configurable | Good | Need full entity for business logic |
[DataSource(Raw)] | No | Fastest | Admin dashboards, bypassing all filters |
| Mutation (entity) | Yes | Standard | Need to modify and save |
Always prefer projections for read endpoints. They fetch only the columns the DTO needs, skip change tracking, and avoid materializing navigations you do not use.
EF Core Integration
Section titled “EF Core Integration”Packages
Section titled “Packages”| Package | Role |
|---|---|
Pragmatic.Persistence | Attributes, interfaces, query/filter primitives |
Pragmatic.Persistence.EFCore | EF Core runtime: DbContext, interceptors, query executor |
Pragmatic.SourceGenerator | Compile-time: generates all .g.cs files |
Interceptors
Section titled “Interceptors”| Interceptor | What It Does |
|---|---|
AuditingInterceptor | Sets CreatedAt/CreatedBy/UpdatedAt/UpdatedBy on SaveChanges |
SoftDeleteInterceptor | Converts Remove() to soft-delete for [SoftDelete] entities |
Global Query Filters
Section titled “Global Query Filters”EF Core global query filters serve as a safety net for raw IQueryable access. They are registered in the generated entity configuration:
// Generated in EntityConfig.Order.g.csbuilder.HasQueryFilter(e => !e.IsDeleted);The Pragmatic filter pipeline (via IQueryFilterProvider) handles the primary filtering. EF Core global filters are the fallback for code paths that bypass the repository.
Migrations
Section titled “Migrations”Each boundary has its own migration context:
dotnet ef migrations add InitBilling --context BillingMigrationDbContextdotnet ef database update --context BillingMigrationDbContextEcosystem Integration
Section titled “Ecosystem Integration”Pragmatic.Persistence is the data layer. Other Pragmatic modules plug into it for deeper integration. Each integration is opt-in: reference the package, and the SG detects it.
Actions
Section titled “Actions”When a class inherits Mutation<TEntity> and has [Mutation] + [Endpoint], the SG generates a MutationInvoker that handles the full load-validate-apply-persist pipeline. DomainAction<T> endpoints can inject IRepository<T, TId> for custom business logic.
Endpoints
Section titled “Endpoints”[Query<T, R>] + [Endpoint] generates a complete search API endpoint. [Mutation] + [Endpoint] generates CRUD endpoints. Query parameters become [FromQuery] bindings automatically.
Mapping
Section titled “Mapping”[MapFrom<TEntity>] on a DTO generates an Expression<Func<TEntity, TDto>> used as the projection in [Query<T, R>]. Only the columns needed by the DTO are fetched from the database.
Validation
Section titled “Validation”[Validate] on a mutation triggers the two-level validation pipeline. Sync validators run attribute-based checks ([Required], [Range]). Async validators perform database-aware checks (uniqueness, existence).
Events
Section titled “Events”Entities implementing IHasDomainEvents dispatch events after SaveChanges. State machine transitions can raise events via [RaisesEvent<T>] on enum values. Events are dispatched through IDomainEventDispatcher.
Caching
Section titled “Caching”[Cacheable] on a query caches PagedResult<T> in HybridCache. [Lookup] on an entity loads all records into memory at startup for synchronous access.
Advanced Features
Section titled “Advanced Features”Temporal Relations
Section titled “Temporal Relations”Track validity periods (ValidFrom / ValidTo) with [TemporalRelation<TParent>]. The SG generates constraint validation, auto-close of previous records, and a temporal query filter.
Inheritance (TPH / TPT / TPC)
Section titled “Inheritance (TPH / TPT / TPC)”[Inheritance(InheritanceStrategy.Tph)] on a base entity generates discriminator configuration. Derived entities get their own Create() factories.
Hierarchy (Self-Referencing Trees)
Section titled “Hierarchy (Self-Referencing Trees)”[GenerateHierarchy] generates GetDescendants() and GetAncestors() using SQL CTEs for recursive queries.
Projectable and ComputedFilter
Section titled “Projectable and ComputedFilter”[Projectable] on a boolean expression property generates Expression<Func<T, bool>> for SQL-translatable WHERE clauses. [ComputedFilter] adds a Specification and a fluent extension method on top.
Lifecycle Hooks
Section titled “Lifecycle Hooks”IEntityLifecycle<T> provides OnCreating and OnSaving hooks for computed defaults and cross-field consistency checks.
Presets
Section titled “Presets”[HasPresets] + [PresetProvider<T>] creates child entities automatically when the parent is created.
See Also
Section titled “See Also”- Getting Started — Install and declare your first entity
- Entity System — Entity declaration, ID types, generated members
- Entity Attributes — Auditable, SoftDelete, ConcurrencyAware, Lookup, StateMachine
- Relationships — One-to-many, many-to-one, many-to-many, cross-boundary
- Repository — CRUD, specifications, include overloads, bulk operations
- Mutations — Create/Update/Delete, collection strategies, nested mutations
- Query Filters — Soft-delete, tenant, permission, filter toggle
- Query System — Declarative queries, filters, sorts, joins
- Grid Filtering — GridFilter, GridAdapter, DevExpress/PrimeNG integration
- Projections and Views — Projectable, ComputedFilter, QueryView
- Query Pipeline — Full pipeline from HTTP to SQL
- Mutation Pipeline — Full pipeline from HTTP to SaveChanges
- Boundaries — Logical partitions, DbContext grouping, transaction scope
- Diagnostics — Understanding PRAG06xx diagnostics
- Common Mistakes — Wrong code, right code for the most common pitfalls
- Troubleshooting — Checklists for generation, DI, filter, and query issues