Pragmatic.Persistence
Source-generated persistence layer for .NET 10. Declare entities with attributes; the source generator
emits repositories, query pipelines, filters, mutations, EF Core configurations, and DI registration,
all visible in obj/, fully debuggable, and free of reflection itself. EF Core underneath is not, and
is the framework’s stated exception.
The Problem
Section titled “The Problem”Every EF Core project accumulates the same infrastructure per entity: identity properties, audit
fields, soft-delete fields, Create factories, typed setters, repository classes, entity
configuration, query filters, DI registration. For one entity with auditing, soft-delete, a
relationship, and a search query, that is ~150 lines of mechanical code. For 30 entities, ~4,500 lines
you write, maintain, and keep in sync, and every new property means touching the entity, the setter,
the factory, the configuration, the DTO mapping, and maybe the query filter.
// Without Pragmatic: ~150 lines per entitypublic class Order : IEntity, IAuditable, ISoftDelete{ public Guid Id { get; set; } public string OrderNumber { get; private set; } = ""; public decimal Total { get; private set; } public DateTimeOffset CreatedAt { get; set; } // repeated on every auditable entity public string? CreatedBy { get; set; } public bool IsDeleted { get; set; } // repeated on every soft-deletable entity public Guid CustomerId { get; private set; } // manual FK public Customer? Customer { get; set; } // manual navigation
public static Order Create(string number, decimal total, Guid customerId) { /* ... */ } public void SetTotal(decimal value) => Total = value; // + audit/soft-delete fields, Equals, GetHashCode, Repository, EF config, query filter, DI...}The Solution
Section titled “The Solution”You declare the entity’s shape and intent. The generator produces everything else at compile time, without reflection.
// With Pragmatic: 10 lines, everything else is generated[Entity][Auditable][SoftDelete][BelongsTo<SalesBoundary>][Relation.OneToMany<LineItem>][Relation.ManyToOne<Customer>]public partial class Order : IEntity{ [LogicKey] public string OrderNumber { get; private set; } = ""; public decimal Total { get; private set; }}From this, the generator emits: PersistenceId, Id, a Create() factory, typed setters, audit +
soft-delete fields, FK + navigation properties, a nested Repository, EF Core entity configuration, a
SoftDeleteFilter, and DI registration. Rename or add a property and the generated
code follows: no drift, no silently-forgotten mapper.
Architecture
Section titled “Architecture”Three packages form the stack:
| Package | Role | Target |
|---|---|---|
| Pragmatic.Persistence | Attributes, interfaces, query/filter primitives, mutation contracts | net10.0 |
| Pragmatic.Persistence.EFCore | EF Core runtime: DbContext generation, interceptors, bulk ops, query executor | net10.0 |
| Pragmatic.SourceGenerator | Unified analyzer that emits all .g.cs files |
netstandard2.0 |
Pragmatic.Persistence is the contract layer (what you declare); Pragmatic.Persistence.EFCore is the
runtime; the generator bridges them at compile time. IRepository<T> and IUnitOfWork live in
Pragmatic.Abstractions (Layer 0) so other packages depend on them without the full stack. Deep dive:
Architecture and Concepts.
Installation
Section titled “Installation”dotnet add package Pragmatic.Persistencedotnet add package Pragmatic.Persistence.EFCoredotnet add package Pragmatic.SourceGenerator # the unified analyzer (ships generated code)Other packages join in when you reference them: the generator detects the reference and emits the matching code, with nothing else to wire.
| Package | What it adds to an entity’s surface |
|---|---|
Pragmatic.Actions |
[DomainAction]: an operation whose body you write; [Mutation]: an operation whose body is derived from the entity’s shape. Both get a generated invoker and a boundary-keyed unit of work |
Pragmatic.Endpoints |
[Endpoint]: the HTTP surface of an action, mutation or query |
Pragmatic.Validation |
property attributes ([Email], [Length], [GreaterThan], …) and the validator the invoker runs before persisting |
Pragmatic.Caching |
[Cacheable] on a query: generated cache key, duration and tag-based invalidation |
Contributors to Pragmatic itself install none of this: inside this repository the packages are
referenced as projects, and every NuGet version is declared once in Directory.Packages.props.
Monorepo Structure is the map of that layout.
Quick Start
Section titled “Quick Start”1. Declare a boundary: a marker class naming one slice of the domain. Every entity carrying
[BelongsTo<CatalogBoundary>] ends up in the same generated CatalogDbContext and behind the same
IUnitOfWork, keyed on the marker type: the boundary is the unit of transactional consistency. The
names are derived from the class with the Boundary suffix trimmed.
[Boundary] // from Pragmatic.Actions: see belowpublic partial class CatalogBoundary;Persistence alone asks for no attribute here: [BelongsTo<T>] on the entity is what assigns it.
[Boundary] is the Actions one, and it does a second thing: every operation in the namespace and its
sub-namespaces is collected into an ICatalogActions interface, with a sub-interface per intermediate
namespace segment (Catalog.Properties.Mutations → ICatalogPropertiesActions). Past two levels of
nesting that grouping is reported as PRAG0412, a warning and not a stop. See
Boundaries.
2. Declare an entity:
[Entity][Auditable][SoftDelete][BelongsTo<CatalogBoundary>][Relation.ManyToMany<Amenity>.WithNavigation("Amenities", Inverse = "Properties")]public partial class Property : IEntity{ [Required, LogicKey] public string Code { get; private set; } = "";
[Required] public string Name { get; private set; } = "";
[Range(1, 5)] public int StarRating { get; private set; }}The generator produces PersistenceId/Id, audit + soft-delete fields, a Create(...) factory, typed
SetXxx() setters, FK + navigation properties, a nested Repository : IRepository<Property>, EF
Core configuration, a SoftDeleteFilter, and DI registration.
3. Write a mutation (auto-maps to the entity setters; the invoker handles create/validate/persist/events):
[Mutation(Mode = MutationMode.Create)][Endpoint(HttpVerb.Post, "api/amenities")]public partial class CreateAmenityMutation : Mutation<Amenity>{ public required string Name { get; init; } public AmenityCategory Category { get; init; }}4. Write a query (the generator builds the LINQ pipeline from [Filter]/[Sort]; the executor
handles paging, projection, global filters, caching):
[Query<Amenity, AmenityDto>][Endpoint(HttpVerb.Get, "api/amenities/search")]public partial class SearchAmenitiesQuery{ [Filter(Operator = FilterOperator.Contains)] public string? Name { get; init; }
[Sort(DefaultDirection = SortDirection.Ascending)] public SortDirection? NameSort { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 50;}5. Use the repository:
public sealed class PropertyService( IRepository<Property> properties, [FromKeyedServices(typeof(CatalogBoundary))] IUnitOfWork uow){ public async Task<Guid> CreateProperty(string code, string name, CancellationToken ct) { var property = Property.Create(code, name); properties.Add(property); await uow.SaveChangesAsync(ct); return property.PersistenceId; }}Full walkthrough: Getting Started.
Operational note
Section titled “Operational note”[WithoutFilter<T>] is a privileged escape hatch. It bypasses the configured query filters
(tenant, soft-delete, ownership, scope) for the duration of a call. Use it only in privileged contexts
(admin tooling, background jobs, cross-tenant reports), never under a normal request principal. Treat
every call site as an authorization boundary. See Query Filters.
Status
Section titled “Status”Stable within 1.0.0-alpha: the core entity, repository, query and mutation surface is settled; some advanced areas are still settling. See the roadmap.
Documentation
Section titled “Documentation”| Guide | What you will learn |
|---|---|
| Architecture and Concepts | Mental model, entity lifecycle, decision tree for choosing patterns |
| Getting Started | Activation requirements, partial classes, minimal example |
| Boundaries | How boundaries partition entities, DbContexts, and transaction scopes |
Entity model
Section titled “Entity model”| Guide | What you’ll learn |
|---|---|
| Entity System | [Entity], PersistenceId, Create(), setters, [LogicKey], [PartOf<TParent>], identifiers (Guid7, OpaqueId, ShortGuid) |
| Attributes | [Auditable], [Audited], [ValueObject], [SoftDelete], [ConcurrencyAware], [Lookup], [StateMachine] |
| Relationships | [Relation.*] attributes, FK generation, cross-boundary rules |
| State Machine | [StateMachine<T>] + [TransitionFrom]: compile-time guarded status transitions |
| Advanced Features | Temporal relations, inheritance, hierarchy, polymorphic, lifecycle, presets, batch |
Data access
Section titled “Data access”| Guide | What you’ll learn |
|---|---|
| Repository | IRepository<T>, concrete repos, specifications, IUnitOfWork, bulk operations |
| Mutations | Mutation<T>, ApplyTo(), collection strategies, modes |
| Patch | [Patch<T>] and its property-set tracking, and why tri-state PATCH needs [GeneratePatch<T>] instead |
Querying
Section titled “Querying”| Guide | What you’ll learn |
|---|---|
| Query System | [Query<T,R>], [Filter], [Sort], [FilterDto<T>], [Join<T>], query executor |
| Query Filters | Soft-delete, tenant, permission, visibility filters, toggle, modes |
| Grid Filtering | [GridFilter<T>], [GridAdapter<T>], DevExpress/PrimeNG adapters |
| Projections & Views | [Projectable], [ComputedFilter], [QueryView<T>], aggregations |
| Query Strategy & Loading | [QueryStrategy], strategies, [LoadWith<T>], split queries |
| Eager Loading | The navigations derived from a DTO’s own shape, and what happens when one is missing |
| Data Ownership & Visibility | [HasOwner], [HasAccessScopes], data-level authorization |
Pipelines & diagnostics
Section titled “Pipelines & diagnostics”| Guide | What you’ll learn |
|---|---|
| Query Pipeline | Endpoint → filters → Apply → count/page/project → result |
| Mutation Pipeline | Endpoint → validation → load/apply → persist → events |
| Diagnostics | PRAG06xx/07xx diagnostics, root causes, mitigations |
| Common Mistakes | Wrong code → right code for the most common pitfalls |
| Troubleshooting | Checklists for generation, DI, filter, query, and migration issues |
EF Core implementation
Section titled “EF Core implementation”DbContext generation · Repository internals · Entity configuration · Interceptors & runtime · Bulk operations · Filter pipeline · Testing · Migration patterns
Requirements
Section titled “Requirements”- .NET 10.0+
Pragmatic.SourceGeneratoranalyzer
License
Section titled “License”Part of the Pragmatic.Design ecosystem. See Licensing. Pragmatic.Persistence is licensed under the PolyForm Small Business 1.0.0 license (free for small businesses; commercial license above the threshold).
