Entity System
This guide explains the beginner mental model for Pragmatic entities:
- you declare intent with attributes
- the generator adds the repetitive persistence members
- EF Core support is generated only when the project also references
Pragmatic.Persistence.EFCoreand thePragmatic.SourceGeneratoranalyzer
If you are new to the stack, read this page before writing your first entity.
The core rule: attributes describe intent
Section titled “The core rule: attributes describe intent”In a normal EF Core project, every entity tends to accumulate the same infrastructure code:
- persistence ID
- equality conventions
- factory methods
- private-set mutation helpers
- indexes for business keys
- navigation wiring
Pragmatic moves that boilerplate into generated code.
You write the business shape. The generator writes the persistence plumbing.
[Entity<TId>]
Section titled “[Entity<TId>]”[Entity<TId>] marks a type as a persistence entity and tells the generator what identifier type it uses.
[Entity<Guid>][BelongsTo<SalesBoundary>]public partial class Order{ public decimal Total { get; private set; }}What this means:
- the type participates in generated persistence artifacts
- the entity gets a generated
PersistenceId - repositories and EF Core configuration can be generated for it
partial is mandatory
Section titled “partial is mandatory”Every generated persistence type must be declared as partial.
Why:
- your source file contains one part of the class
- the generator emits another part in a
.g.csfile - C# merges them only when both declarations are
partial
Broken:
[Entity<Guid>]public class Order{ public decimal Total { get; private set; }}Correct:
[Entity<Guid>]public partial class Order{ public decimal Total { get; private set; }}If you forget this, the generator emits diagnostics such as PRAG0600.
What gets generated
Section titled “What gets generated”For a simple entity, the generator can add:
PersistenceIdIdconvenience aliasCreate(...)factory- setter helpers for
private setproperties - repository and EF Core configuration, when EF Core generation is active
Example source:
[Entity<Guid>][BelongsTo<SalesBoundary>]public partial class Order{ [LogicKey] public string OrderNumber { get; private set; } = ""; public decimal Total { get; private set; }}Typical generated members:
public partial class Order : IEntity<Guid>{ public Guid PersistenceId { get; set; } public Guid Id => PersistenceId;}The exact generated file set depends on the attributes present on the entity and on whether EF Core generation is active.
Choosing an ID type
Section titled “Choosing an ID type”| Type | Typical assignment strategy | Good default? |
|---|---|---|
Guid | Generated Guid7 / UUID v7 | Yes, for most entities |
int | Database-generated identity | Good when the database is the source of ID creation |
long | Database-generated identity | Same as int, with more headroom |
string | Provided explicitly by your code | Use only when the string itself is the real identifier |
For novice teams, Guid is usually the safest default because it works well in distributed systems and does not require the database to create the ID first.
PersistenceId vs business identifiers
Section titled “PersistenceId vs business identifiers”Pragmatic separates two ideas:
PersistenceId: the technical identifier used for persistence identity- business identifiers such as order number or SKU: human-facing keys
Do not overload one to behave like the other unless the business model truly demands it.
Generated Create(...)
Section titled “Generated Create(...)”For non-abstract entities, the generator can produce a static Create(...) factory that initializes required members and sets the persistence ID.
Typical outcome:
var order = Order.Create("ORD-001", 120m);Why this is useful:
- creation stays consistent
- required values are obvious at the call site
- the ID strategy stays centralized
For novice users, the simplest rule is:
- prefer the generated
Create(...)when it exists - do not manually assign generated persistence members unless the model explicitly requires it
Generated setters
Section titled “Generated setters”Properties with private set can still be updated through generated typed setters.
That keeps the public surface controlled while still allowing generated mutation pipelines and repository helpers to work cleanly.
Example source:
[Entity<Guid>]public partial class Product{ public string Name { get; private set; } = ""; public decimal Price { get; private set; }}Typical generated helpers:
public partial class Product{ public void SetName(string value) => Name = value; public void SetPrice(decimal value) => Price = value;}[LogicKey]
Section titled “[LogicKey]”Use [LogicKey] for a human-meaningful business identifier such as:
- SKU
- order number
- invoice number
- slug
Example:
[Entity<Guid>]public partial class Product{ [LogicKey] public string Sku { get; private set; } = "";
public string Name { get; private set; } = "";}What it gives you:
- A unique index in the generated EF Core configuration.
- A generated lookup helper on the concrete repository class, such as
GetBySkuAsync(...).
Important nuance:
GetBySkuAsync(...)is generated onProduct.Repository(nested class)- it is not part of the stable
IRepository<Product, Guid>abstraction
That distinction matters for dependency injection:
- inject
IRepository<TEntity, TId>for stable CRUD/specification behavior - inject the concrete repository when you need logic-key helpers
[AlternateKey]
Section titled “[AlternateKey]”[AlternateKey] is for generated business keys that follow a format.
Example:
[Entity<Guid>]public partial class Invoice{ [AlternateKey("INV-{YYYY}{MM}-{SEQ:5}")] public string InvoiceNumber { get; private set; } = "";
public decimal Total { get; private set; }}Supported placeholder families include:
{YYYY},{YY}{MM},{DD}{SEQ:N}{RANDOM:N}{GUID:N}
Use this when the business key has a real formatting rule.
Do not use it just to make IDs look nicer. A business key should exist because the business uses it.
[AggregateRoot]
Section titled “[AggregateRoot]”[AggregateRoot] marks the entry point of an aggregate.
[Entity<Guid>][AggregateRoot][Relation.OneToMany<LineItem>]public partial class Order{ [LogicKey] public string OrderNumber { get; private set; } = "";}Why this matters:
- mutations should target the aggregate root, not arbitrary child entities
- the attribute lets the generator validate that rule
For novice teams, a good rule of thumb is:
- if an entity should not be edited independently from its parent, it probably should not be the mutation target
Relations belong in attributes
Section titled “Relations belong in attributes”Pragmatic expects persistence relations to be declared with [Relation.*].
Examples:
[Relation.OneToMany<OrderLine>][Relation.ManyToOne<Customer>.WithNavigation("Customer", Inverse = "Orders")]Why the explicit model matters:
- navigation names become predictable
- inverse relations can be validated
- generated configuration stays coherent
- ambiguous manual navigation properties are caught early by diagnostics such as
PRAG0610toPRAG0615
For novice users, the safest rule is:
- let relation attributes be the source of truth
- avoid mixing ad-hoc manual navigation properties with generated relation metadata
[ValueObject]
Section titled “[ValueObject]”[ValueObject] is for immutable value-centric types, usually represented as records.
Example:
[ValueObject]public partial record EmailAddress{ public string Value { get; init; } = "";
private static Result<EmailAddress, ValidationError> Validate(string value) { if (!value.Contains('@')) return new ValidationError("email", "Invalid email format");
return new EmailAddress { Value = value }; }}The generator can then provide creation helpers such as Create(...) and CreateUnsafe(...).
Use a value object when:
- equality should be by value, not by identity
- the type has validation rules of its own
- the concept is richer than a raw primitive
Common mistakes
Section titled “Common mistakes”Treating LogicKey as the entity ID
Section titled “Treating LogicKey as the entity ID”Keep the technical ID and the business key separate unless the business key is truly immutable and universal.
Forgetting partial
Section titled “Forgetting partial”This is still the most common onboarding mistake.
Expecting interface injection to expose convenience helpers
Section titled “Expecting interface injection to expose convenience helpers”Stable interfaces intentionally stay small. If you need GetBySkuAsync(...), inject the concrete generated repository.
Adding business formatting before defining business rules
Section titled “Adding business formatting before defining business rules”[AlternateKey] and [LogicKey] add constraints. Use them after the business semantics are clear, not before.