Common Mistakes
These are the most common issues developers encounter when using Pragmatic.Persistence. Each section shows the wrong approach, the correct approach, and explains why.
1. Forgetting partial on the Entity Class
Section titled “1. Forgetting partial on the Entity Class”Wrong:
[Entity<Guid>][BelongsTo<SalesBoundary>]public class Order{ public string OrderNumber { get; private set; } = ""; public decimal Total { get; private set; }}Compile result: PRAG0600 error — “Entity type ‘Order’ must be declared as partial”.
Right:
[Entity<Guid>][BelongsTo<SalesBoundary>]public partial class Order{ public string OrderNumber { get; private set; } = ""; public decimal Total { get; private set; }}Why: The source generator emits PersistenceId, Id, Create(), SetXxx(), equality members, and the nested Repository class into a separate .g.cs file. Without partial, the compiler cannot merge your declaration with the generated one. This is the most common onboarding mistake.
2. Entity Without [BelongsTo<TBoundary>]
Section titled “2. Entity Without [BelongsTo<TBoundary>]”Wrong:
[Entity<Guid>]public partial class Invoice{ public decimal Total { get; private set; }}Compile result: The SG generates basic entity members (PersistenceId, Id, Create(), setters) but does not generate: the repository, the EF Core entity configuration, the DbContext DbSet, or the DI registration. At runtime, IRepository<Invoice, Guid> cannot be resolved from DI.
Right:
[Entity<Guid>][BelongsTo<BillingBoundary>]public partial class Invoice{ public decimal Total { get; private set; }}Why: Boundaries are the organizing unit for persistence. Without [BelongsTo], the SG does not know which DbContext should include the entity. Repository generation, entity configuration, and DI registration all depend on the boundary assignment. If you see “No service for type IRepository<Invoice, Guid> has been registered”, the first thing to check is the [BelongsTo] attribute.
3. Manual Navigation Properties Instead of [Relation.*]
Section titled “3. Manual Navigation Properties Instead of [Relation.*]”Wrong:
[Entity<Guid>][BelongsTo<SalesBoundary>]public partial class Order{ public Guid CustomerId { get; private set; } public Customer? Customer { get; set; } // Manual navigation public ICollection<LineItem> Items { get; } = []; // Manual collection}Compile result: PRAG0610 error for the collection navigation and PRAG0611 error for the reference navigation — “navigation property exists without a matching [Relation.*] attribute”.
Right:
[Entity<Guid>][BelongsTo<SalesBoundary>][Relation.ManyToOne<Customer>][Relation.OneToMany<LineItem>]public partial class Order{ public string OrderNumber { get; private set; } = "";}Why: Pragmatic requires [Relation.*] attributes as the single source of truth for relationships. The SG generates FK properties, navigation properties, and EF Core configuration from these attributes. Mixing manual navigation properties with generated ones creates ambiguity: the generator cannot determine if the manual property is intentional, a leftover, or incomplete. If you need a specific FK property name for other generators (like [MapFrom]), you can declare the FK property yourself — the SG detects it and uses it instead of generating a new one.
4. Forgetting [Database] or [Persist] on the DbContext
Section titled “4. Forgetting [Database] or [Persist] on the DbContext”Wrong:
// No database declaration in the host project// Entities have [BelongsTo<BillingBoundary>] but nothing configures the DbContextRuntime result: The SG generates repositories and entity configurations, but no DbContext is configured. At startup, InvalidOperationException because the DbContext is not registered in DI.
Right:
// In the host project[Database<SQLite>("App", ConnectionString = "App")][PersistAll]public partial class AppDatabase;Or for specific boundaries:
[Database<PostgreSQL>("Billing")][Persist<BillingBoundary>]public partial class BillingDatabase;Why: The SG generates entity configurations and repositories in the module assembly, but the DbContext itself is configured in the host project. The [Database] attribute tells the SG which provider to use, and [PersistAll] or [Persist<TBoundary>] tells it which entities to include. Without this, the DbContext is never registered and nothing connects to the database. Also ensure the DbContext class is partial (diagnostic PRAG0602).
5. Using new Entity() Instead of Create()
Section titled “5. Using new Entity() Instead of Create()”Wrong:
var order = new Order();order.SetOrderNumber("ORD-001");order.SetTotal(150m);orders.Add(order);Runtime result: The entity has PersistenceId == Guid.Empty (or 0 for int keys). EF Core may throw on save because the primary key is unset, or worse, silently insert a row with a zero/empty ID.
Right:
var order = Order.Create("ORD-001", 150m);orders.Add(order);Why: The generated Create() factory initializes PersistenceId with a Guid7 value (for Guid keys), sets all required properties, and ensures the entity is in a valid state. Using new bypasses the ID assignment and any lifecycle hooks registered for entity creation. Always prefer the generated Create() factory when it exists.
6. Forgetting to Register Generated Query Filters
Section titled “6. Forgetting to Register Generated Query Filters”Wrong:
// Program.cs / IStartupStepservices.AddBillingDbContext(o => o.UseNpgsql(connection));services.AddBillingRepositories();// Missing: services.AddMyAppQueryFilters();Runtime result: Repositories resolve correctly. Queries execute. But soft-deleted rows appear in results, tenant isolation is not enforced, and temporal filters do not apply. Everything looks like it works until you notice deleted records showing up.
Right:
services.AddBillingDbContext(o => o.UseNpgsql(connection));services.AddBillingRepositories();services.AddMyAppQueryFilters(); // Register generated query filtersWhy: The SG generates query filter classes (like Order.SoftDeleteFilter) and a registration extension method (typically AddMyAppQueryFilters() or AddGeneratedQueryFilters()). Without calling this method, the filters exist as generated code but are never registered in DI. The repository’s read paths call IQueryFilterProvider.GetCombinedFilter<T>(), which returns no filters if none are registered. Always include the filter registration alongside DbContext and repository registration.
7. Querying Across Boundaries with Include()
Section titled “7. Querying Across Boundaries with Include()”Wrong:
// Invoice is in BillingBoundary, Reservation is in BookingBoundaryvar invoice = await invoiceRepo.GetByIdAsync( invoiceId, q => q.Include(i => i.Reservation), // Cross-boundary Include! ct);Runtime result: Compile error or runtime InvalidOperationException — Reservation is not part of the Billing DbContext. Navigation property Invoice.Reservation does not exist because cross-boundary navigations are not generated.
Right:
var invoice = await invoiceRepo.GetByIdAsync(invoiceId, ct);var reservation = await reservationRepo.GetByIdAsync(invoice.ReservationId, ct);Why: Each boundary maps to a separate DbContext. Entities in different boundaries live in different database contexts, so EF Core Include() cannot traverse between them. The SG generates only the FK property (ReservationId) for cross-boundary relations — not the navigation property. Diagnostic PRAG0617 (Info) informs you of this behavior. Load cross-boundary data with separate repository calls.
8. Using typeof() Instead of Generic Attributes
Section titled “8. Using typeof() Instead of Generic Attributes”Wrong:
[BelongsTo(typeof(BillingBoundary))][Relation.ManyToOne(typeof(Customer))]public partial class Invoice { /* ... */ }Compile result: These non-generic overloads may not exist. If they do, they lose compile-time type safety — the SG cannot validate the type constraint at the attribute level.
Right:
[BelongsTo<BillingBoundary>][Relation.ManyToOne<Customer>]public partial class Invoice { /* ... */ }Why: Pragmatic attributes are always generic. [BelongsTo<T>], [Entity<TId>], [Relation.ManyToOne<T>], [StateMachine<TEnum>] — all use type parameters. The generic form provides compile-time type checking (the referenced type must exist and be accessible) and produces cleaner code without typeof() noise.
9. Expecting IRepository to Expose Logic Key Helpers
Section titled “9. Expecting IRepository to Expose Logic Key Helpers”Wrong:
public class OrderService(IRepository<Order, Guid> orders){ public Task<Order?> GetByNumber(string number, CancellationToken ct) => orders.GetByOrderNumberAsync(number, ct); // Does not compile!}Compile result: IRepository<Order, Guid> does not have GetByOrderNumberAsync. This method exists only on the generated concrete repository Order.Repository.
Right:
public class OrderService(Order.Repository orders){ public Task<Order?> GetByNumber(string number, CancellationToken ct) => orders.GetByOrderNumberAsync(number, ct); // Works}Or, if you want to keep the stable interface for other operations:
public class OrderService( IRepository<Order, Guid> orders, // Stable CRUD Order.Repository orderRepo) // Concrete for LogicKey{ // Use orders for Add/Remove/Find // Use orderRepo for GetByOrderNumberAsync}Why: The stable IRepository<T, TId> interface intentionally stays small — GetByIdAsync, FindAsync, CountAsync, ExistsAsync, Add, Remove, Update. Logic key helpers, include overloads, and bulk methods are on the concrete generated repository because they are entity-specific. This separation keeps the interface stable across all entities while allowing per-entity convenience methods on the concrete type.
10. Nullable Properties on Create Mutations
Section titled “10. Nullable Properties on Create Mutations”Wrong:
[Mutation(Mode = MutationMode.Create)]public partial class CreateOrderMutation : Mutation<Order>{ public string? OrderNumber { get; init; } // Nullable on create! public decimal? Total { get; init; } // Nullable on create!}Runtime result: Both properties can be null. The generated ApplyToEntity will skip them (null = “don’t change”), so the entity gets created with default values for OrderNumber and Total — probably empty string and zero. No validation error is raised.
Right:
[Mutation(Mode = MutationMode.Create)]public partial class CreateOrderMutation : Mutation<Order>{ public required string OrderNumber { get; init; } // Required on create public required decimal Total { get; init; } // Required on create}Why: In MutationMode.Create, you typically want all essential properties to be required. The required keyword makes the SG generate a body DTO that rejects requests with missing fields. In MutationMode.Update, properties are nullable for partial updates — null means “don’t change this field”. Mixing these conventions between modes leads to silent data quality issues.
11. Throwing Exceptions Instead of Returning Errors
Section titled “11. Throwing Exceptions Instead of Returning Errors”Wrong:
[Mutation(Mode = MutationMode.Update)]public partial class ConfirmOrderMutation : Mutation<Order, ConflictError>{ public required Guid Id { get; init; }
public override async Task<Result<Order, IError>> ApplyAsync( Order entity, CancellationToken ct) { if (entity.Status != OrderStatus.Pending) throw new InvalidOperationException("Order must be pending");
entity.TransitionTo(OrderStatus.Approved); return entity; }}Runtime result: The exception propagates as a 500 Internal Server Error. The ConflictError in the class signature is never used. The OpenAPI spec declares a 409 response that never happens.
Right:
[Mutation(Mode = MutationMode.Update)]public partial class ConfirmOrderMutation : Mutation<Order, ConflictError>{ public required Guid Id { get; init; }
public override async Task<Result<Order, IError>> ApplyAsync( Order entity, CancellationToken ct) { if (entity.Status != OrderStatus.Pending) return new ConflictError("Order", $"Order must be Pending, but is {entity.Status}");
var result = entity.TransitionTo(OrderStatus.Approved); if (result.IsFailure) return new ConflictError("Order", result.Error.ToString());
return entity; }}Why: The mutation pipeline uses the Result pattern. The generated endpoint handler maps each error type to an HTTP status code (NotFoundError = 404, ConflictError = 409, ValidationError = 400). Throwing an exception bypasses the pipeline, loses type information, and always produces 500. Reserve exceptions for truly unexpected failures (database connection lost), not for expected business conditions.
12. Disabling Query Filters Too Broadly
Section titled “12. Disabling Query Filters Too Broadly”Wrong:
public class OrderService(IQueryFilterToggle filterToggle){ public async Task<List<Order>> GetRecentOrders(CancellationToken ct) { using (filterToggle.UseMode(FilterMode.Raw)) { // Raw mode bypasses ALL filters: soft-delete, tenant, permission return await orders.FindAsync( Spec<Order>.Where(o => o.CreatedAt > DateTimeOffset.UtcNow.AddDays(-7)), ct); } }}Runtime result: Works, but returns soft-deleted orders, orders from other tenants, and orders the current user should not see. In a multi-tenant system, this is a data leak.
Right:
public class OrderService(IQueryFilterToggle filterToggle){ public async Task<List<Order>> GetRecentOrdersIncludingDeleted(CancellationToken ct) { // Disable only the specific filter you need to bypass using (filterToggle.Disable<SoftDeleteFilter_Order>()) { return await orders.FindAsync( Spec<Order>.Where(o => o.CreatedAt > DateTimeOffset.UtcNow.AddDays(-7)), ct); } }}Or if you need an admin view:
using (filterToggle.UseMode(FilterMode.Admin)){ // Skips visibility and permission filters, keeps soft-delete and tenant}Why: Filter modes are ordered by strictness: Normal > Admin > Background > Raw. Use the narrowest scope that satisfies your requirement. FilterMode.Raw is for migrations, data repair scripts, and support tooling — not for regular business logic. If a screen always needs raw data, that is often a modeling smell.
Quick Reference
Section titled “Quick Reference”| Mistake | Diagnostic / Symptom |
|---|---|
Missing partial | PRAG0600 compile error |
Missing [BelongsTo] | No repository/DbContext generated, DI resolution failure |
| Manual navigation property | PRAG0610 / PRAG0611 compile error |
Missing [Database] / [Persist] | DbContext not registered, InvalidOperationException |
new Entity() instead of Create() | PersistenceId == Guid.Empty, save failure or silent zero-ID |
Missing AddMyAppQueryFilters() | Deleted/tenant rows visible, no filter enforcement |
Cross-boundary Include() | Compile error or InvalidOperationException |
typeof() in attribute | Compile error or lost type safety |
| IRepository for logic key helpers | Compile error, method not on interface |
| Nullable props on Create mutation | Silent default values, missing required data |
| Throwing exceptions in ApplyAsync | 500 instead of typed HTTP status |
FilterMode.Raw in business logic | Data leak, deleted/tenant rows visible |