Skip to content

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.


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.


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 DbContext

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


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 / IStartupStep
services.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 filters

Why: 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 BookingBoundary
var invoice = await invoiceRepo.GetByIdAsync(
invoiceId,
q => q.Include(i => i.Reservation), // Cross-boundary Include!
ct);

Runtime result: Compile error or runtime InvalidOperationExceptionReservation 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.


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.


MistakeDiagnostic / Symptom
Missing partialPRAG0600 compile error
Missing [BelongsTo]No repository/DbContext generated, DI resolution failure
Manual navigation propertyPRAG0610 / 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 attributeCompile error or lost type safety
IRepository for logic key helpersCompile error, method not on interface
Nullable props on Create mutationSilent default values, missing required data
Throwing exceptions in ApplyAsync500 instead of typed HTTP status
FilterMode.Raw in business logicData leak, deleted/tenant rows visible