Skip to content

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.EFCore and the Pragmatic.SourceGenerator analyzer

If you are new to the stack, read this page before writing your first entity.

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>] 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

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.cs file
  • 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.

For a simple entity, the generator can add:

  • PersistenceId
  • Id convenience alias
  • Create(...) factory
  • setter helpers for private set properties
  • 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.

TypeTypical assignment strategyGood default?
GuidGenerated Guid7 / UUID v7Yes, for most entities
intDatabase-generated identityGood when the database is the source of ID creation
longDatabase-generated identitySame as int, with more headroom
stringProvided explicitly by your codeUse 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.

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.

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

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

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:

  1. A unique index in the generated EF Core configuration.
  2. A generated lookup helper on the concrete repository class, such as GetBySkuAsync(...).

Important nuance:

  • GetBySkuAsync(...) is generated on Product.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] 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] 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

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 PRAG0610 to PRAG0615

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] 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

Keep the technical ID and the business key separate unless the business key is truly immutable and universal.

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.