Skip to content

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.


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.

// Entity — you write every property, every interface, every factory
public 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 one
public 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 entity
public 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/page
public 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 boundary
services.AddScoped<IRepository<Order, Guid>, OrderRepository>();
services.AddScoped<IRepository<LineItem, Guid>, LineItemRepository>();
services.AddScoped<IRepository<Customer, Guid>, CustomerRepository>();
// ... for every entity

For 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.


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 implementation
  • CreatedAt, CreatedBy, UpdatedAt, UpdatedBy (from [Auditable])
  • IsDeleted, DeletedAt, DeletedBy (from [SoftDelete])
  • static Create(...) factory with all required properties
  • SetOrderNumber(string), SetTotal(decimal) typed setters
  • CustomerId FK property, Customer navigation, Items collection (from [Relation.*])
  • Order.Repository nested class implementing IRepository<Order, Guid> with GetByOrderNumberAsync
  • EntityConfig.Order.g.cs with key, indexes, relationships, query filter
  • Order.SoftDeleteFilter implementing IQueryFilter<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.


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)
|
v
Pragmatic.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
|
v
Pragmatic.Persistence.EFCore (runtime)
|
+---> DbContext Boundary-scoped, with generated DbSets
+---> Interceptors AuditingInterceptor, SoftDeleteInterceptor
+---> Query Executor EfCoreQueryExecutor with filter pipeline
+---> Filter Pipeline Root + navigation filtering
|
v
Database (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.


The entity system is the foundation. Everything else — repositories, queries, mutations, filters — builds on entity declarations.

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.

TypeGeneration StrategyWhen to Use
GuidGuid7 / UUID v7 (time-ordered)Default for most entities. Works in distributed systems.
intDatabase auto-incrementWhen the database owns ID creation.
longDatabase auto-incrementSame as int with more headroom.
stringMust be set explicitlyWhen 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.

From [Entity<Guid>] alone, the SG produces:

MemberPurpose
Guid PersistenceId { get; set; }The primary key.
Guid Id => PersistenceIdTyped 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.

Traits are attributes that add cross-cutting fields and behavior to entities. Each trait adds properties, an interface implementation, and infrastructure hooks.

TraitInterfaceAdded PropertiesRuntime Behavior
[Auditable]IAuditableCreatedAt, CreatedBy, UpdatedAt, UpdatedByAuditingInterceptor sets values on save
[SoftDelete]ISoftDeleteIsDeleted, DeletedAt, DeletedByRemove() sets fields instead of DELETE. Query filter hides deleted rows.
[SoftDelete(Cascade = true)]ISoftDeleteSame as aboveSoft-deleting parent cascades to children
[ConcurrencyAware]IConcurrencyAwarebyte[] RowVersionEF 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; }
}

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.

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.


All relationships are declared via [Relation.*] attributes on the entity class. The SG generates FK properties, navigation properties, and EF Core configuration.

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

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)

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.

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.


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 boundaries
public class BillingBoundary;
public class BookingBoundary;
// Assign entities
[Entity<Guid>]
[BelongsTo<BillingBoundary>]
public partial class Invoice { /* ... */ }
[Entity<Guid>]
[BelongsTo<BookingBoundary>]
public partial class Reservation { /* ... */ }
Without BoundariesWith Boundaries
One giant DbContext with all entitiesFocused DbContexts per domain (10-30 entities each)
Slow model building at startupFast model building
No isolation between domainsClear ownership and access control
All entities share one connection stringDifferent boundaries can use different databases
One transaction for everythingBoundary = unit of transactional consistency

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.

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

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.

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);
}
}
NeedInject
Stable CRUD / specification accessIRepository<Order, Guid>
Read-only stable accessIReadRepository<Order, Guid>
Logic key helper or include overloadOrder.Repository (concrete)
Bulk operationsOrder.Repository (concrete)

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.

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.


The query system lets you declare what to filter, sort, and project — the SG produces the implementation.

[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> implementation
  • Apply(IQueryable<Order>) with WHERE + ORDER BY
  • Projection expression from [MapFrom<Order>] on the DTO
OperatorSQL EquivalentTypical Use
Equals= valueExact match (ID, status, boolean)
NotEquals!= valueExclusion filter
ContainsLIKE '%value%'Text search
StartsWithLIKE 'value%'Prefix search
GreaterThan / GreaterOrEqual> / >=Range (dates, amounts)
LessThan / LessOrEqual< / <=Range
InIN (values)Multi-select (statuses, categories)
Between>= min AND <= maxDate ranges, price ranges

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);
HTTP GET /api/orders/search?Status=Pending&Page=2
|
v
1. Model binding --> Query object
2. IQueryExecutor.ExecuteAsync()
3. Prepare source (NoTracking, SplitQuery, IgnoreGlobalFilters)
4. Apply global filters (soft-delete, tenant, permission)
5. query.Apply() -- YOUR generated WHERE + ORDER BY
6. COUNT(*), SKIP/TAKE, SELECT projection
7. Return PagedResult<TResult>
|
v
HTTP 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 are safety rails that enforce data visibility rules. You define them once; they apply automatically on every generated read path.

SourceEffect
[SoftDelete] on entityHides rows where IsDeleted == true
Tenant-aware entity (implements ITenantEntity)Restricts rows to TenantId == currentTenant
[TemporalRelation] on entityHides inactive historical rows
ModeSoft-DeleteTenantVisibilityPermission
NormalAppliedAppliedAppliedApplied
AdminAppliedAppliedSkippedSkipped
BackgroundAppliedSkippedSkippedSkipped
RawSkippedSkippedSkippedSkipped

Use IQueryFilterToggle for scoped overrides:

// Disable a specific filter
using (filterToggle.Disable<SoftDeleteFilter_Order>())
{
var allOrders = await orders.Query().ToListAsync(ct); // Includes deleted
}
// Switch to admin mode
using (filterToggle.UseMode(FilterMode.Admin))
{
// Skip visibility and permission filters
}

The override is temporary — AsyncLocal-scoped, restored automatically on dispose.


Mutations are operations that modify a single entity through the MutationInvoker pipeline. A mutation class inherits from Mutation<TEntity> and declares properties to map.

ModeBehavior
CreateCreates a new entity via Create() factory, applies properties, saves
UpdateLoads entity by ID, applies non-null properties (partial update), saves
DeleteLoads entity, marks deleted (soft-delete if [SoftDelete]), saves
RestoreLoads bypassing all filters, resets IsDeleted/DeletedAt/DeletedBy, saves
HTTP POST /api/v1/invoices
|
v
1. 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
|
v
Result<TEntity, IError>
|
v
HTTP 201 Created / 400 / 404
LevelTargetWhenWhat It Catches
L1Mutation DTOBefore entity loadInvalid input (missing fields, bad format, non-existent references)
L2EntityAfter applyBusiness 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.

When a mutation includes a collection property, you choose how to update:

StrategyBehavior
ReplaceClear existing, add all from DTO
MergeMatch by ID: update existing, add new, remove missing
AppendAdd all from DTO, keep existing
[Mutation]
public partial class UpdateOrderDto
{
[CollectionStrategy(CollectionMutationStrategy.Merge)]
public List<UpdateLineDto>? Lines { get; init; }
}

For each entity marked with [Entity<TId>], the SG produces files depending on the attributes present. Here is the complete table:

Generated FileContentCondition
{Entity}.g.csPersistenceId, Id, interface implementationAlways
{Entity}.Create.g.csStatic Create(...) factoryNon-abstract entities
{Entity}.Setters.g.csTyped SetXxx() for each private set propertyHas settable properties
{Entity}.Auditable.g.csCreatedAt, CreatedBy, UpdatedAt, UpdatedBy[Auditable]
{Entity}.SoftDelete.g.csIsDeleted, DeletedAt, DeletedBy[SoftDelete]
{Entity}.Relations.g.csFK properties, navigation properties[Relation.*]
{Entity}.Repository.g.csNested Entity.Repository classHas [BelongsTo] + EFCore ref
{Entity}.SoftDeleteFilter.g.csNested SoftDeleteFilter query filter[SoftDelete]
{Entity}.TenantFilter.g.csNested TenantFilter query filterImplements ITenantEntity
{Entity}.StateMachine.g.csStatus, TransitionTo(), CanTransitionTo()[StateMachine<TEnum>]
{Entity}.Projectable.g.csExpression fields for computed properties[Projectable] / [ComputedFilter]
EntityConfig.{Entity}.g.csEF Core IEntityTypeConfiguration (host-level)Has [BelongsTo] + EFCore ref
_Infra.Persistence.Registration.g.csDI registration for DbContext, repos, filtersOnce per assembly
DbContext.{Boundary}.g.csBoundary DbContext with DbSetsPer 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.


ScenarioPatternWhy
Simple read by IDIReadRepository<T, TId>.GetByIdAsync()Direct, no overhead
Read by business keyEntity.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 DTOMutation<T> with MutationMode.CreateGenerated load-apply-save
Update entity (partial)Mutation<T> with MutationMode.UpdateNullable props = skip unchanged
Delete (soft)Mutation<T> with MutationMode.DeleteAuto soft-delete for [SoftDelete] entities
Complex business operationDomainAction<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
ApproachTrackingPerformanceUse When
[Query<T, R>] (with projection)NoBestRead-only endpoints, API responses
[Query<T>] (entity)ConfigurableGoodNeed full entity for business logic
[DataSource(Raw)]NoFastestAdmin dashboards, bypassing all filters
Mutation (entity)YesStandardNeed 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.


PackageRole
Pragmatic.PersistenceAttributes, interfaces, query/filter primitives
Pragmatic.Persistence.EFCoreEF Core runtime: DbContext, interceptors, query executor
Pragmatic.SourceGeneratorCompile-time: generates all .g.cs files
InterceptorWhat It Does
AuditingInterceptorSets CreatedAt/CreatedBy/UpdatedAt/UpdatedBy on SaveChanges
SoftDeleteInterceptorConverts Remove() to soft-delete for [SoftDelete] entities

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.cs
builder.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.

Each boundary has its own migration context:

Terminal window
dotnet ef migrations add InitBilling --context BillingMigrationDbContext
dotnet ef database update --context BillingMigrationDbContext

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.

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.

[Query<T, R>] + [Endpoint] generates a complete search API endpoint. [Mutation] + [Endpoint] generates CRUD endpoints. Query parameters become [FromQuery] bindings automatically.

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

[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).

Entities implementing IHasDomainEvents dispatch events after SaveChanges. State machine transitions can raise events via [RaisesEvent<T>] on enum values. Events are dispatched through IDomainEventDispatcher.

[Cacheable] on a query caches PagedResult<T> in HybridCache. [Lookup] on an entity loads all records into memory at startup for synchronous access.


Track validity periods (ValidFrom / ValidTo) with [TemporalRelation<TParent>]. The SG generates constraint validation, auto-close of previous records, and a temporal query filter.

[Inheritance(InheritanceStrategy.Tph)] on a base entity generates discriminator configuration. Derived entities get their own Create() factories.

[GenerateHierarchy] generates GetDescendants() and GetAncestors() using SQL CTEs for recursive queries.

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

IEntityLifecycle<T> provides OnCreating and OnSaving hooks for computed defaults and cross-field consistency checks.

[HasPresets] + [PresetProvider<T>] creates child entities automatically when the parent is created.