git:20261003.e164ceb to git:20261004.7a4079a
103 added, 103 removed. Audit A to A.
---
name: pragmatic-use-actions-endpoints
- description: Use when implementing a CRUD or business operation or exposing it over HTTP — Mutation, DomainAction, [Endpoint], validation and authorization on an action, with Pragmatic.Actions and Pragmatic.Endpoints.
+ description: Use when implementing a CRUD or business operation or exposing it over HTTP (Mutation, DomainAction, [Endpoint], validation and authorization on an action, with Pragmatic.Actions and Pragmatic.Endpoints).
---
# Pragmatic Use Actions And Endpoints
**Covers:** Implement domain operations (Mutation, DomainAction) and expose them as HTTP APIs with Pragmatic.Actions + Pragmatic.Endpoints consumed from NuGet. Attribute-first pattern driven by the source generator.
`Pragmatic.Actions` models business behaviour; `Pragmatic.Endpoints` exposes it over HTTP. Both are **attribute-driven, code-generation**: you decorate a class, the unified source generator produces the invoker (DI + pipeline) and the ASP.NET mapping. The consumer writes no manual handlers.
## When to use
- You are implementing a write/command (`Mutation`) or an orchestration (`DomainAction`).
- You are exposing an operation or query over HTTP with `[Endpoint]`.
- You are versioning APIs, adding processors, rate limiting, or mapping errors.
For entities/queries/repositories: `pragmatic-use-persistence`. For DI/host: `pragmatic-use-composition`. For permissions on actions: `pragmatic-use-authorization`.
## Packages
```xml
<PackageReference Include="Pragmatic.Actions" Version="1.0.0-alpha.1" />
<PackageReference Include="Pragmatic.Endpoints" Version="1.0.0-alpha.1" />
<PackageReference Include="Pragmatic.Result" Version="1.0.0-alpha.1" />
<PackageReference Include="Pragmatic.Validation" Version="1.0.0-alpha.1" /> <!-- if you validate input -->
<PackageReference Include="Pragmatic.SourceGenerator" Version="1.0.0-alpha.1">
<IncludeAssets>analyzers; build; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
```
Dense API surface with all signatures: **`../pragmatic-ecosystem/references/api-surface/actions-endpoints.md`**.
Cross-cutting patterns catalogue: **`../pragmatic-ecosystem/references/patterns-map.md`** (P05–P08).
## Mental model
```
You decorate Source generator produces You / client consumes
[Mutation] → {Type}.MutationInvoker.g.cs → dispatcher resolves the invoker
[DomainAction] → {Type}.Invoker.g.cs → IDomainActionInvoker<T> in DI
[Endpoint] → {Type}.Endpoint.g.cs → ASP.NET route mounted
private field → {Type}.SetDependencies.g.cs → dependencies resolved from DI
```
## The three operation forms
| Form | Base class | Purpose | Persists? |
|---|---|---|---|
| **Mutation** | `Mutation<TEntity>` | CRUD on a single entity | Yes, automatic `SaveChanges` |
| **DomainAction** | `DomainAction<TReturn>` | Orchestration with multiple dependencies, returns a value | Yes if inside the boundary |
| **VoidDomainAction** | `VoidDomainAction` | Orchestration with no return value | Yes if inside the boundary |
Put **business behaviour** in Actions/Mutations, never in generated handlers.
## Core patterns
### 1. Boundary
Mark the domain library (namespace `Pragmatic.Actions.Attributes`):
```csharp
[Boundary] public partial class SalesBoundary;
```
- All code under the boundary's namespace flows into it; sub-folders (`Orders/`, `Customers/`) create inferred sub-boundaries — the SG generates separate action interfaces (`ISalesOrdersActions`) composed into `ISalesActions`.
+ All code under the boundary's namespace flows into it; sub-folders (`Orders/`, `Customers/`) create inferred sub-boundaries: the SG generates separate action interfaces (`ISalesOrdersActions`) composed into `ISalesActions`.
**Those interfaces are how another boundary calls this one. Do not hand-write a contract beside them.**
```csharp
public partial class WriteStoryAction : DomainAction<WriteStoryResult>
{
private IKnowledgeActions _knowledge = null!; // injected, AddScoped by the generator
- // Items is the sub-boundary — the composed interface exposes each one as a property.
+ // Items is the sub-boundary; the composed interface exposes each one as a property.
var r = await _knowledge.Items.IngestText(itemId, text, slot: "", ct);
}
```
What the generator emits per boundary, all registered for you:
| | |
|---|---|
| `I{Boundary}{Sub}Actions` | one method per action/mutation, in **two forms**: taking the action object, and with the parameters flattened |
- | `I{Boundary}Actions` | the composed one — sub-boundaries are **properties**, not flattened methods |
+ | `I{Boundary}Actions` | the composed one: sub-boundaries are **properties**, not flattened methods |
| `I{Boundary}InternalActions` | the same, plus the operations kept off the public one. `internal`, so same-assembly only |
| `{Boundary}LocalActions` / `{Boundary}RemoteActions` | in-process today; HTTP the day the boundary is split out, with no change at the call site |
#### What is on the public interface, and what is not
**An operation with no `[Endpoint]` is internal.** Leaving the endpoint out says it is not HTTP
surface, and it almost always means the operation is a step or a helper. It lands on
- `I{Boundary}InternalActions` only — reachable from its own assembly, invisible to other modules.
+ `I{Boundary}InternalActions` only: reachable from its own assembly, invisible to other modules.
`Internal` has three states, on `[DomainAction]` and on `[Mutation]` alike:
| Written | Meaning |
|---|---|
| nothing | inferred from `[Endpoint]`: routed is surface, unrouted is not |
- | `Internal = false` | public anyway — **this is how an endpoint-less operation is offered** |
+ | `Internal = false` | public anyway: **this is how an endpoint-less operation is offered** |
| `Internal = true` | internal anyway, endpoint or not |
```csharp
// No endpoint, and called from another module on every story written: it has to say it is offered.
[DomainAction(Internal = false)]
[UndoWith<RemoveIngestedText>]
public partial class IngestTextAction : DomainAction<IngestTextResult>;
// A step of a composite. Nothing declared, and nothing needs to be.
[DomainAction]
public partial class ResolveCoveredCandidatesAction : DomainAction<int>;
```
⚠️ **To call an internal operation from your own module, inject `I{Boundary}InternalActions`.** It is
generated for every boundary and is the point of it.
- ⚠️ **If the operation is in a group, inject the group's twin — `I{Boundary}{Group}InternalActions` —
+ ⚠️ **If the operation is in a group, inject the group's twin (`I{Boundary}{Group}InternalActions`)
or reach it as `root.{Group}`.** Both resolve the same object, so it is a question of how short you
- want the call to be. A `[MessageHandler]` or a `[Job]` — the two callers that arrive with no principal —
+ want the call to be. A `[MessageHandler]` or a `[Job]` (the two callers that arrive with no principal)
are the ones that usually want this interface.
- ⚠️ **`[Boundary(Visibility = Internal)]` is a different thing** — it hides the *whole boundary*, and a
+ ⚠️ **`[Boundary(Visibility = Internal)]` is a different thing**: it hides the *whole boundary*, and a
boundary hidden that way cannot be consumed remotely either. `Internal` on an operation hides *one*.
### Handing a mutation a row you already hold
The internal interface publishes a second shape for every non-`Create` mutation that names an entity:
```csharp
- // You read the row through the repository — the query filters have already had their say.
+ // You read the row through the repository; the query filters have already had their say.
var candidate = await _candidates.GetByIdAsync(id, ct);
// Hand it over. Without this overload the mutation would load it again: GetByIdAsync is a query,
// not a FindAsync answering from the identity map, so the second read is real.
await _knowledge.Candidates.SettleCandidate(mutation, candidate, ct);
```
⚠️ **On the internal interface only, and that is not a compromise.** The public one is implemented
- over HTTP as well, and a tracked entity does not cross a process boundary — publishing it there would
+ over HTTP as well, and a tracked entity does not cross a process boundary: publishing it there would
force a remote method whose only outcome is a throw. ⚠️ And never for a `Create`: supplying an entity
to a mutation whose mode is to create one contradicts it.
⚠️ **A grouped boundary gets a twin per group.** `I{Boundary}{Group}InternalActions` extends the
group's public interface, and the root's internal interface re-declares the property with `new` and
- the twin's type — so `boundary.Group.Operation(mutation, entity, ct)` and the ordinary
+ the twin's type, so `boundary.Group.Operation(mutation, entity, ct)` and the ordinary
`boundary.Group.Operation(mutation, ct)` are both on one seam. A group with nothing to add gets no
- twin — and "nothing to add" is stricter than it sounds: an ordinary update mutation supplies a
+ twin, and "nothing to add" is stricter than it sounds: an ordinary update mutation supplies a
preloaded shape by itself, so only a group whose every member creates ends up without one.
⚠️ **The group's public interface and its twin are two different objects, deliberately.** The public
one resolves to the *guarded* implementation, which lets the invoked operation's own permission be
asked; the twin resolves to the unguarded one the root holds. On a group the distinction is drawn by
- which instance answers, not by which interface you inject — so injecting the public group interface
+ which instance answers, not by which interface you inject, so injecting the public group interface
from a caller with no principal fails a permission check rather than skipping it, which is the
opposite of what the shorter name suggests.
⚠️ **Public and internal members of a sub-boundary both sit under the group.** If a call does not
resolve, look at the generated `_Boundary.{Boundary}.Definition.g.cs` rather than guessing: it is the
answer, and it is inspectable.
⚠️ **No endpoint does not mean unreachable from another process.** The host generates a
`/_pragmatic/invoke` dispatcher that routes by action type, so an exported operation with no route is
still callable across a `[RemoteBoundary<T>]`.
⚠️ **Writing your own `ITermExtraction`-style service instead is the mistake to avoid.** It duplicates
- a contract that already exists, it does not survive the boundary being split out, and — the part that
- bites — a hand-written service has no invoker, so it loses the permission check, validation, the unit
+ a contract that already exists, it does not survive the boundary being split out, and (the part that
+ bites) a hand-written service has no invoker, so it loses the permission check, validation, the unit
of work and the audit entry that the generated path applies. The symptom is a service full of
`entity.SetXxx(…)` doing by hand what a mutation maps by name.
A hand-written interface is still right for **data** another boundary needs to read (`ISupplierDirectory`
returning contacts). The generated ones are for **operations**.
- The generated facade carries `[BoundaryActions<TBoundary>]`. You never write it — it is the marker that
+ The generated facade carries `[BoundaryActions<TBoundary>]`. You never write it: it is the marker that
makes a cross-boundary call recognisable to the analyzer instead of matching an `I{X}Actions` name the
compiler cannot check.
- #### Writing across boundaries — PRAG0424
+ #### Writing across boundaries: PRAG0424
Reading across boundaries is a join; writing across them is not. Each boundary owns a `DbContext` and
saves it separately, inner first, so a caller that fails **after** the inner call leaves the inner
writes committed. Measured on a real application: five rows survived a failure, pointing at a parent
row that was never written.
- `PRAG0424` warns when one invocation commits into more than one store — not on any cross-boundary
- call, which alone is atomic — and asks for a decision. Two ways to record it:
+ `PRAG0424` warns when one invocation commits into more than one store (not on any cross-boundary
+ call, which alone is atomic) and asks for a decision. Two ways to record it:
```csharp
// Repair: the inner step knows how to undo itself.
[DomainAction]
[UndoWith<RemoveIngestedText>]
public partial class IngestTextAction : DomainAction<IngestResult>;
public sealed class RemoveIngestedText(IRepository<Term> terms) : ICompensates<IngestResult>
{
public Task<VoidResult<IError>> Undo(IngestResult committed, CancellationToken ct = default) { … }
}
// Accept: the leftovers are harmless, and the reason says why.
[DomainAction]
[AcceptsPartialWrites("The glossary keeps unreferenced candidates; a nightly job prunes them.")]
public partial class WriteStoryAction : DomainAction<WriteStoryResult>;
```
`[UndoWith<T>]` registers the undo **after** the inner commit succeeds; a later failure in the same
- request runs the registered undos in reverse. `T` is an ordinary scoped service — the generator
- registers it — implementing `ICompensates<TReturn>` (or `ICompensatesVoid`), and `PRAG0425` is an
+ request runs the registered undos in reverse. `T` is an ordinary scoped service (the generator
+ registers it) implementing `ICompensates<TReturn>` (or `ICompensatesVoid`), and `PRAG0425` is an
error when it does not.
⚠️ **Best effort, in-request, and that is the whole guarantee.** A crash between the inner commit and
the undo leaves the work committed: nothing is durable, nothing is retried. That is where a saga
- starts — and note Messaging has its own `[CompensateWith<TAction>]` for saga steps, which is the
+ starts, and note Messaging has its own `[CompensateWith<TAction>]` for saga steps, which is the
durable one and a different attribute.
`PRAG0429` says exactly that, at `Info`, on the call: **an undo across a boundary is a saga's
- compensating step without a saga's durability.** It does not ask for a change — the decision was made
- and recorded, and warning twice would be nagging — it makes sure whoever reads the call knows what they
+ compensating step without a saga's durability.** It does not ask for a change (the decision was made
+ and recorded, and warning twice would be nagging); it makes sure whoever reads the call knows what they
are looking at without reading the framework.
⚠️ **Behind a `[RemoteBoundary<T>]` the compensator never runs at all.** It executes in the process that
- owns the action; the caller holds an HTTP proxy. Only the host can see this — the module compiles the
- same whether it is hosted or called — so the host reports it as `PRAG1689`. Publish an event or model
+ owns the action; the caller holds an HTTP proxy. Only the host can see this (the module compiles the
+ same whether it is hosted or called), so the host reports it as `PRAG1689`. Publish an event or model
the undo as a saga; there is no attribute that makes an undo cross a process.
A compensator that itself fails is logged at `Error` and returned to the caller as
`COMPENSATION_FAILED`, carrying both errors: the response says the system is inconsistent, not merely
that the operation failed. Each facade method whose action declares an undo is marked
- `[CompensableStep]`, and PRAG0424 goes quiet for callers that invoke those steps — per method, so a
+ `[CompensableStep]`, and PRAG0424 goes quiet for callers that invoke those steps, per method, so a
caller never has to compensate what it does not call.
The diagnostic reads the **calls**, not the fields: a facade held and never invoked commits nothing.
### 2. Mutation
```csharp
using Pragmatic.Actions.Mutation;
using Pragmatic.Endpoints.Attributes;
using System.ComponentModel.DataAnnotations;
[Mutation(Mode = MutationMode.Create)] // Create|Update|CreateOrUpdate|Delete|Restore
[Endpoint(HttpVerb.Post, "api/orders")]
public partial class CreateOrderMutation : Mutation<Order>
{
[Required] public required Guid CustomerId { get; init; }
[Range(0.01, double.MaxValue)] public required decimal Total { get; init; }
}
```
- `Mode` can be omitted if it can be inferred from the name prefix (`Create*`→Create, `Update*`→Update, `Delete*`→Delete); otherwise **PRAG0410**.
- `ReturnType`: `Entity` (default: the entity, or its `[ReturnsDto<T>]`), `Id` (the boundary returns the `Guid`, the endpoint `{"id": …}`), `LogicalKey` (a generated `{Mutation}.LogicalKey` record with the `[LogicKey]` parts; PRAG0403 when the entity has none). `[ReturnsDto<T>]` beside `Id` or `LogicalKey` is PRAG0535: the key answers. A Create answering 201 gets a `Location` when a `Single` query answers at its route plus `/{id}`. `[Mutation(Mode = Delete, SoftDelete = true)]` for logical delete.
- The SG generates `ApplyToEntity(entity)` with **auto-mapping** property→`entity.SetXxx(...)`. A mutation property without a corresponding setter on the entity → **PRAG0414**.
- The SG generates `{Type}.MutationInvoker.g.cs` with pipeline: input validation → load/create entity → auto-map → `ApplyAsync` → entity validation → persist → `SaveChanges`.
- Custom logic (cross-field rules, domain methods): override `ApplyAsync` — the auto-mapping has **already** been applied before it runs.
+ Custom logic (cross-field rules, domain methods): override `ApplyAsync`; the auto-mapping has **already** been applied before it runs.
- A **state transition** is declared, not written: `[TransitionsTo<TState>(target)]` and the generated invoker moves the entity (a mutation's own, or the one `[LoadEntity]` row of an action whose `[StateMachine<TState>]` matches), answering 409 — documented — when the state machine refuses:
+ A **state transition** is declared, not written: `[TransitionsTo<TState>(target)]` and the generated invoker moves the entity (a mutation's own, or the one `[LoadEntity]` row of an action whose `[StateMachine<TState>]` matches), answering 409 (documented) when the state machine refuses:
```csharp
[Mutation(Mode = MutationMode.Update)]
[Endpoint(HttpVerb.Post, "api/orders/{id}/submit")]
[TransitionsTo<OrderStatus>(OrderStatus.Submitted)]
public partial class SubmitOrderMutation : Mutation<Order>
{
public required Guid Id { get; init; }
}
```
- - `When = TransitionTiming.BeforeBody` (default): moved before the body, which sees the new state; a refusal skips the body. The only one for an action — it builds its response in the body.
+ - `When = TransitionTiming.BeforeBody` (default): moved before the body, which sees the new state; a refusal skips the body. The only one for an action, which builds its response in the body.
- `When = TransitionTiming.AfterBody` (mutations only, **PRAG0467** on an action): moved after a successful body, so the body's own refusals keep their own code.
- - `When = TransitionTiming.ByBody`: the body moves it — typically a domain method writing state plus who/when/why — and the invoker throws if a successful body left another state; `IsConditional = true` drops that check for a move made only sometimes.
+ - `When = TransitionTiming.ByBody`: the body moves it (typically a domain method writing state plus who/when/why), and the invoker throws if a successful body left another state; `IsConditional = true` drops that check for a move made only sometimes.
- ⚠️ Keeping `entity.TransitionTo(target)` in the body with BeforeBody/AfterBody is **PRAG0466** (the second call is refused as target → target, a 409 every time). **PRAG0465**: no entity with that state machine, or several loaded. **PRAG0468**: not an `Update` mutation.
#### How many transactions a shape costs
Measured, not preferred (`CommitCountTests`, `NestedMutationEventsTests`, Showcase, PostgreSQL):
| Shape | `SaveChanges` |
|---|---|
- | Three mutations invoked as independent roots | **3** — one each |
- | Mutations invoked from inside an action of the **same** boundary | **1** — the action's |
+ | Three mutations invoked as independent roots | **3**, one each |
+ | Mutations invoked from inside an action of the **same** boundary | **1**, the action's |
| `[CompositeAction]` with 2 steps | **1** |
| A `DomainAction` that builds N entities with setters | **1** |
- | A step in **another** boundary | **its own** — nobody else can save its `DbContext` |
+ | A step in **another** boundary | **its own**: nobody else can save its `DbContext` |
The rule behind the numbers: **the outermost invoker that owns a unit of work commits it, once.**
Anything nested inside it holding the same unit of work stages its writes and defers its events; the
root saves and then flushes them. It is decided by unit of work identity at runtime, so it holds
- however the chain is written — through a service, a helper, a loop.
+ however the chain is written: through a service, a helper, a loop.
So the choice is about meaning, not cost:
- **A mutation** for one entity written from named inputs: property mapping, L1/L2 validation,
permissions, events and the endpoint, for free. Invoking one in a loop from an action is a normal
- thing to do and costs one transaction — see `CancelReservationsForPropertyAction`.
+ thing to do and costs one transaction; see `CancelReservationsForPropertyAction`.
- **`[CompositeAction]`** when the steps are known at compile time and must be atomic as a set. The
- steps may be **mutations, actions or void actions**, in any mix — each is a property typed as the step
+ steps may be **mutations, actions or void actions**, in any mix; each is a property typed as the step
type, and the generated invoker runs them in order. **Declare the steps and nothing else**: the base
demands an `Execute` and a composite has no use for one, so the generator writes it. (It throws:
being called would mean the composite was invoked around its own invoker.) Write one yourself and it
is left alone.
- It commits **once**, by construction, which is what a composite is. `[CommitStrategy(PerStep)]` on
one contradicts that and is `PRAG0430`.
- `[Transactional]` **is** honoured on a composite, and buys the one thing a plain composite cannot
give: a step that reads what an earlier step wrote. Without it the steps merely stage, so a step
- whose logic is a *query* sees the store as it was before the composite began — and reports success
+ whose logic is a *query* sees the store as it was before the composite began, and reports success
having done nothing.
- ⚠️⚠️ **Exposed with `[Endpoint]`, it must declare its own `[RequirePermission]`.** The steps run as
- internal calls and their permissions are deliberately **not** re-checked — the composite is the
+ internal calls and their permissions are deliberately **not** re-checked: the composite is the
authorization boundary. A composite without one answers **`204` to a caller holding no permissions
at all** and writes every row, where each step's own endpoint would have answered `403`. Nothing
- **`PRAG0440`** refuses it — `[AllowAnonymous]` is how a composite says it is deliberately public.
+ **`PRAG0440`** refuses it; `[AllowAnonymous]` is how a composite says it is deliberately public.
`RequireAuthorizationByDefault` does not cover this: it demands only that the caller be
authenticated.
- ⚠️ **A collection is not a set of steps.** Steps are read one property at a time, and a property
- whose type is not itself a mutation or an action — `List<TMutation>`, an array, anything wrapping
- them — is skipped with no diagnostic (`PRAG0427` fires only at *zero* steps). A variable number of
+ whose type is not itself a mutation or an action (`List<TMutation>`, an array, anything wrapping
+ them) is skipped with no diagnostic (`PRAG0427` fires only at *zero* steps). A variable number of
children arriving in one nested DTO is `[PartOf<TParent>]` with a `CollectionStrategy`, not this.
- Over HTTP a step property is an ordinary body property, so the generated request body **nests one
JSON object per step** and the nested step type is registered in the generated
- `JsonSerializerContext` — the path is AOT-safe. ⚠️ It nests the *mutation type*, not the
- `{Mutation}Body` its own endpoint uses, so what that body would have excluded — the implicit `Id` of
- an `Update` step — is on the wire.
+ `JsonSerializerContext`; the path is AOT-safe. ⚠️ It nests the *mutation type*, not the
+ `{Mutation}Body` its own endpoint uses, so what that body would have excluded (the implicit `Id` of
+ an `Update` step) is on the wire.
- **Setters inside one action** when the rows are one fact and no step deserves its own name,
validation or event.
- #### Raising a domain event — `[Raises<TEvent>]`
+ #### Raising a domain event: `[Raises<TEvent>]`
Declare the event on the operation and the generated invoker builds and dispatches it **after the
commit**. Constructor parameters bind by name to the operation's input properties, and for a mutation
to the entity's as well, with `Id`/`PersistenceId`/`{Entity}Id` all resolving to the entity's id.
`OccurredAt` is filled in.
```csharp
[Mutation(Mode = MutationMode.Update)]
[Raises<ReservationCancelled>]
public partial class CancelReservationMutation : Mutation<Reservation>
{
public required string Reason { get; init; } // binds to the event's Reason
}
```
- ⚠️ **A parameter that matches nothing is passed as `default`** — `Guid.Empty`, or `null` — and the
+ ⚠️ **A parameter that matches nothing is passed as `default`** (`Guid.Empty`, or `null`), and the
event is dispatched anyway: the write happened, the handler ran, and only the contents are wrong. That
is `PRAG0433`, and it is almost always a typo in a name.
**Nested operations defer their events.** A mutation invoked from inside an action of the same boundary
does not commit, so it hands its events to the batch the action owns and the action flushes them after
- the single commit — all of them, in order. Measured across a boundary: N cancellations in one pass
+ the single commit, all of them, in order. Measured across a boundary: N cancellations in one pass
produce N handler runs, and dropping any one of them fails `DeferredEventsTests`.
⚠️ Inside `[Transactional]` the dispatch is still tied to the **save**, not to the transaction's commit,
so handlers run before the commit lands. The framework says so and logs it; where that matters, the
outbox is the answer.
- #### Composing, and saying how it commits — PRAG0428
+ #### Composing, and saying how it commits: PRAG0428
An action that invokes other actions or mutations has **three** possible outcomes, and none of them is
- implied by the code. `PRAG0428` asks which one, and only of an action that actually composes — one that
+ implied by the code. `PRAG0428` asks which one, and only of an action that actually composes; one that
invokes nothing is a single transaction whatever the default and is never questioned.
| Declaration | What it means |
|---|---|
| `[Transactional]` | One database transaction, and **each step saves as it goes**, so a later step reads what an earlier one wrote. Costs a round trip per step. |
- | `[CommitStrategy(CommitMode.Once)]` | One commit at the end. Atomic, but the steps cannot see each other — a query in step 2 runs against the store as it was before step 1. |
+ | `[CommitStrategy(CommitMode.Once)]` | One commit at the end. Atomic, but the steps cannot see each other: a query in step 2 runs against the store as it was before step 1. |
| `[CommitStrategy(CommitMode.PerStep)]` | Independent steps, each committing on its own. A failure keeps what came before. |
⚠️ **A `[Transactional]` operation can run twice.** The generated host turns on `EnableRetryOnFailure()` for
SQL Server, PostgreSQL and MySQL. A retrying strategy refuses a transaction opened outside it, so the invoker runs
the whole invocation inside it. On a transient failure before the commit (a dropped connection, a failover),
**the operation runs again**: filters, loading, the body and the save, from a cleared change tracker.
- A body must have **no effect outside its transaction**. Mail, messages and events go through the outbox,
which is transactional.
- A direct HTTP call, a file write, or a call into another boundary (which commits on its own) runs again.
- What follows the commit (events, cache invalidation, after-filters) runs once.
No diagnostic checks this: whether a body reaches outside is not something the generator can read off it.
An application can change the retry, or turn it off, with `AddBoundary<T>(cfg => cfg.UseDatabase(…))`,
which is applied after the generated provider call.
**Say it once on the boundary** when the answer never varies:
```csharp
[Boundary]
[CommitStrategy(CommitMode.Once)]
public partial class KnowledgeBoundary;
```
Every composing action of that boundary inherits it, and an action that needs something else still says
- so — the nearer declaration wins. A boundary *is* a transaction boundary, so this is a sentence it can
+ so; the nearer declaration wins. A boundary *is* a transaction boundary, so this is a sentence it can
say; `[Transactional]` is not (`PRAG0431`), because a transaction is opened per invocation and as a
boundary-wide default it would buy a round trip on every action, including the ones that write nothing.
⚠️ Both attributes act on the invoker's **unit of work**, and there is one only when the action belongs
- to a boundary. The boundary is inferred from the entity behind an `IRepository` field — which an action
+ to a boundary. The boundary is inferred from the entity behind an `IRepository` field, which an action
that *only* composes does not have. Name it: `[BelongsTo<TBoundary>]`. Without it the attribute parses
and does nothing, which is `PRAG0432`.
⚠️ A commit strategy governs **one** unit of work. A step in another boundary commits through its own
whatever you write, because no invoker can save a `DbContext` it does not hold. Crossing that line is
what `PRAG0424` reports and `[UndoWith<T>]` or a saga answers.
- ⚠️ `BatchContext` defers the unit of work you name — `new BatchContext(unitOfWork)` — and **performs no
+ ⚠️ `BatchContext` defers the unit of work you name (`new BatchContext(unitOfWork)`) and **performs no
commit itself**: you save. Opening one also stops the invoker from claiming the commit, which is the
point. The parameterless form still covers every unit of work, including boundaries you cannot save.
### 3. DomainAction
```csharp
using Pragmatic.Actions.Abstractions;
using Pragmatic.Actions.Attributes;
[DomainAction]
[LoadEntity<Order>(nameof(Id))] // loads _order, or answers 404 before Execute
[Endpoint(HttpVerb.Post, "api/orders/{id}/refund")]
public partial class RefundOrderAction : DomainAction<RefundReceipt, NotFoundError>
{
private IPaymentService _payments = null!; // dependency: uninitialised private field
public required Guid Id { get; init; } // bound from route {id}
public override async Task<Result<RefundReceipt, IError>> Execute(CancellationToken ct)
{
// _order is generated by [LoadEntity] and is never null here; changes to it are saved by the invoker.
// ...
}
}
```
- **What does not go in `Execute`** — each has a declared home, and writing it by hand is the mistake the
+ **What does not go in `Execute`**: each has a declared home, and writing it by hand is the mistake the
examples kept repeating:
| Instead of, in `Execute` | Declare |
|---|---|
| `var x = await repo.GetByIdAsync(Id); if (x is null) return NotFoundError…` | `[LoadEntity<T>(nameof(Id))]` → field `_x`, 404 by the invoker, after validation and authorization (no 404-vs-403 probing), past a `[WithoutFilter<T>]` the operation declares. The key is the entity's key type (**PRAG0411**) |
- | a second query for a navigation of a preloaded entity (`FindMembersOfAsync(teamId)` beside `[LoadEntity<Team>]`) | `[LoadEntity<Team>(nameof(Id), Include = "Members")]` — dotted paths, comma-separated; the key may live inside the request — `[LoadEntity<RoomType>("Request.RoomTypeId")]`, every segment a property;
- without it `_team.Members` is **empty** (no lazy loading); a path that names no navigation is **PRAG0453** — as is an `[EagerLoad]` path of a mutation (**PRAG0736** on a query), checked at build time and not emitted |
+ | a second query for a navigation of a preloaded entity (`FindMembersOfAsync(teamId)` beside `[LoadEntity<Team>]`) | `[LoadEntity<Team>(nameof(Id), Include = "Members")]`: dotted paths, comma-separated; the key may live inside the request (`[LoadEntity<RoomType>("Request.RoomTypeId")]`, every segment a property);
+ without it `_team.Members` is **empty** (no lazy loading); a path that names no navigation is **PRAG0453**, as is an `[EagerLoad]` path of a mutation (**PRAG0736** on a query), checked at build time and not emitted |
| `var rows = await repo.FindAsync(Spec.Where(x => ids.Contains(x.PersistenceId)))` and a comparison for the missing ones | `[LoadEntities<T>(nameof(Ids))]` on an `IReadOnlyList<Guid>`/array/list → field `IReadOnlyList<T> _ts` (plural; `FieldName` overrides), **one** query, rows in the order of the keys, duplicates once; the missing keys are **one** 404 naming them all (`NotFoundError.ForAll`); empty list → empty field, no query. Not a collection of the key type → **PRAG0411** |
- | `var x = await repo.FirstOrDefaultAsync(Specs.ActiveWithNumber(Number)); if (x is null) return NotFound…` / `var xs = await repo.FindAsync(Specs.PendingOf(Id))` | `[LoadEntity<T>(Specification = nameof(TSpecifications.ActiveWithNumber))]` / `[LoadEntities<T>(Specification = …)]` — the rule's parameters bind **by name** to the operation's properties (`PendingOf(Guid id)` ← `Id`); single: none is 404; list: none is `[]`, or 404 with `RequireAny = true`. Not a specification of the entity **PRAG0454**, unbound parameter **PRAG0455**, key and rule together **PRAG0456** |
- | `var x = await repo.GetByNumberAsync(Number, ct); if (x is null) return NotFoundError…` — a row named by its domain key (a number, a code, a slug) | `[LoadEntity<T>(nameof(Number), By = nameof(T.Number))]` — read through the generated logic-key lookup (`{Entity}Specifications.GetBy{Key}Async`), same filters and tracking as by id; 404 carries the value. `By` not the entity's single-part `[LogicKey]` (or beside a `Specification`) **PRAG0460**, property of another type **PRAG0461** |
- | two `[LoadEntity]` of one entity, or two round trips for one table | give each a distinct `FieldName` — `[LoadEntity<Employee>(nameof(ManagerId), FieldName = "_manager")]`, `[LoadEntity<Employee>(nameof(DeputyId), FieldName = "_deputy")]`: read in **one** `WHERE Id IN (…)`, each field from it, 404 per missing key; different entities stay separate queries |
- | the row only has to exist — a foreign key in the body of a create: `[LoadEntity<T>]` for a row nobody reads, or the database's FK violation answering | `[RequireExists<T>(nameof(TeamId))]` — `ExistsAsync` before the body (same filters, an `EXISTS`, nothing loaded, no field), 404 naming the key; a null nullable key is not checked; on a mutation the key is still written. Key of another type **PRAG0411**; beside a `[LoadEntity]` of the same key **PRAG0462** (only the load runs) |
- | `var r = await _boundary.Group.GetX(new GetXQuery { A = A }, ct); if (r.IsFailure) return …; var data = r.Value;` — a declared query's answer inside an operation | `[LoadFrom<GetXQuery>] private IReadOnlyList<XDto> Data { get; set; } = [];` — the query's inputs bind **by name** from the operation's properties (a private computed one is fine: `private int Year => From.Year;`); run by the **query's own invoker** (its permission asked of the caller — the internal facade skips it); its failure is the operation's; the property is no input. Not the answer type **PRAG0458**, unbound required input **PRAG0459** |
+ | `var x = await repo.FirstOrDefaultAsync(Specs.ActiveWithNumber(Number)); if (x is null) return NotFound…` / `var xs = await repo.FindAsync(Specs.PendingOf(Id))` | `[LoadEntity<T>(Specification = nameof(TSpecifications.ActiveWithNumber))]` / `[LoadEntities<T>(Specification = …)]`: the rule's parameters bind **by name** to the operation's properties (`PendingOf(Guid id)` ← `Id`); single: none is 404; list: none is `[]`, or 404 with `RequireAny = true`. Not a specification of the entity **PRAG0454**, unbound parameter **PRAG0455**, key and rule together **PRAG0456** |
+ | `var x = await repo.GetByNumberAsync(Number, ct); if (x is null) return NotFoundError…`, a row named by its domain key (a number, a code, a slug) | `[LoadEntity<T>(nameof(Number), By = nameof(T.Number))]`: read through the generated logic-key lookup (`{Entity}Specifications.GetBy{Key}Async`), same filters and tracking as by id; 404 carries the value. `By` not the entity's single-part `[LogicKey]` (or beside a `Specification`) **PRAG0460**, property of another type **PRAG0461** |
+ | two `[LoadEntity]` of one entity, or two round trips for one table | give each a distinct `FieldName` (`[LoadEntity<Employee>(nameof(ManagerId), FieldName = "_manager")]`, `[LoadEntity<Employee>(nameof(DeputyId), FieldName = "_deputy")]`): read in **one** `WHERE Id IN (…)`, each field from it, 404 per missing key; different entities stay separate queries |
+ | the row only has to exist (a foreign key in the body of a create): `[LoadEntity<T>]` for a row nobody reads, or the database's FK violation answering | `[RequireExists<T>(nameof(TeamId))]`: `ExistsAsync` before the body (same filters, an `EXISTS`, nothing loaded, no field), 404 naming the key; a null nullable key is not checked; on a mutation the key is still written. Key of another type **PRAG0411**; beside a `[LoadEntity]` of the same key **PRAG0462** (only the load runs) |
+ | `var r = await _boundary.Group.GetX(new GetXQuery { A = A }, ct); if (r.IsFailure) return …; var data = r.Value;`, a declared query's answer inside an operation | `[LoadFrom<GetXQuery>] private IReadOnlyList<XDto> Data { get; set; } = [];`: the query's inputs bind **by name** from the operation's properties (a private computed one is fine: `private int Year => From.Year;`); run by the **query's own invoker** (its permission asked of the caller; the internal facade skips it); its failure is the operation's; the property is no input. Not the answer type **PRAG0458**, unbound required input **PRAG0459** |
| an operation that hands back what it loaded and should answer only a caller who may read those rows | `RequireReadPermission = true` on the load: the entity's CRUD `Read` permission, checked **before** the read (401/403); an entity with no known read permission is **PRAG0457** |
- | `if (ManagerId is { } id) { var m = await repo.GetByIdAsync(id); … }` — load it only if given | `[LoadEntity<T>(nameof(ManagerId))]` on a `Guid? ManagerId`: field `T?`, nothing read when null, 404 when it names no row |
- | `if (To < From) return ValidationError.For(…)` — the shape of the input | validation attributes on the property (`[GreaterThanOrEqualProperty(nameof(From))]`, `[Range]`, a custom `ValidationAttribute`), message via `MessageKey = TKeys.…` |
- | a rule on an entity the operation loads (`[LoadEntity]`, `[LoadCurrentUser]`) — "hours only on a kind counted in hours" | `private ValidationError ValidateLoaded()` (or `Task<ValidationError> ValidateLoadedAsync(CancellationToken)`) on the operation: the invoker calls it right after the preload, before `Execute`, and a failure is the 422 with its fields. Not a validator that reads the row a second time. Another signature with that name is **PRAG0452** |
+ | `if (ManagerId is { } id) { var m = await repo.GetByIdAsync(id); … }`, to load it only if given | `[LoadEntity<T>(nameof(ManagerId))]` on a `Guid? ManagerId`: field `T?`, nothing read when null, 404 when it names no row |
+ | `if (To < From) return ValidationError.For(…)`, the shape of the input | validation attributes on the property (`[GreaterThanOrEqualProperty(nameof(From))]`, `[Range]`, a custom `ValidationAttribute`), message via `MessageKey = TKeys.…` |
+ | a rule on an entity the operation loads (`[LoadEntity]`, `[LoadCurrentUser]`), such as "hours only on a kind counted in hours" | `private ValidationError ValidateLoaded()` (or `Task<ValidationError> ValidateLoadedAsync(CancellationToken)`) on the operation: the invoker calls it right after the preload, before `Execute`, and a failure is the 422 with its fields. Not a validator that reads the row a second time. Another signature with that name is **PRAG0452** |
| a rule that needs a lookup of something the operation does not load (a uniqueness) | a `[Validator]` class implementing `IAsyncValidator<TOperation>` (same assembly) |
| `repo.FindAsync(Spec<T>.Where(x => …))` inline | a named specification in the entity's generated `static partial class {Entity}Specifications` (a file next to the entity, entity namespace); the generator gives `repo.Find{Name}Async(…)` |
- | `Ensure.ThrowIfNull(found)` for a missing row | nothing — `Ensure.ThrowIf*` is for a broken invariant (a bug → exception → 500), never for a 404 or a 422 |
- | `private IClock _clock = null!;` only to read `_clock.UtcNow` / today | `[FromClock] public DateTimeOffset Now { get; private set; }` (or `DateOnly Today` → `IClock.UtcToday`), written by the invoker before `Execute`/`ApplyAsync` — the registered `IClock`, so a test that pins it pins this too |
+ | `Ensure.ThrowIfNull(found)` for a missing row | nothing: `Ensure.ThrowIf*` is for a broken invariant (a bug → exception → 500), never for a 404 or a 422 |
+ | `private IClock _clock = null!;` only to read `_clock.UtcNow` / today | `[FromClock] public DateTimeOffset Now { get; private set; }` (or `DateOnly Today` → `IClock.UtcToday`), written by the invoker before `Execute`/`ApplyAsync` from the registered `IClock`, so a test that pins it pins this too |
| `private EmployeeResolver _currentEmployee` + `ResolveAsync` + `if (employee is null) return NotFoundError…` | `[LoadCurrentUser]` → field `_current{User}` (`_currentEmployee`), filled by the invoker through the generated resolver after authorization: 401 for nobody signed in, 404 for an account with no user entity (**PRAG0451** when the module has no `[PragmaticUser]`) |
| `ICurrentUser` injected only to read `.Id` | `[FromCurrentUser] public string CallerId { get; private set; }` (401 when not authenticated), or `[FromCurrentUser(nameof(Employee.Id))]` for a member of the `[PragmaticUser]` entity (404 when the caller has none) |
- What stays in `Execute`: what only the domain can answer — its typed errors (overlap, overdraft,
- state) — and the writes. The signed-in user's entity is `[LoadCurrentUser]`; one member of it, as a
+ What stays in `Execute`: what only the domain can answer, its typed errors (overlap, overdraft,
+ state), and the writes. The signed-in user's entity is `[LoadCurrentUser]`; one member of it, as a
property, is `[FromCurrentUser(nameof(Employee.Id))]`.
`[FromClock]` / `[FromCurrentUser]` on an action or a mutation mean what they mean on a query: the
property is `{ get; private set; }` (**PRAG0730**/**PRAG0734** otherwise), it is never a parameter
- (body, route, OpenAPI, boundary overload), and the invoker writes it after validation and authorization
- — so a validation attribute on it sees `default`. On a mutation it is also written to the entity when the
+ (body, route, OpenAPI, boundary overload), and the invoker writes it after validation and authorization,
+ so a validation attribute on it sees `default`. On a mutation it is also written to the entity when the
entity has a member of the same name (`[FromClock] DateOnly DecidedOn` → `entity.DecidedOn`); without one
it is simply the body's to read, and no `PRAG0414` is raised.
- **Dependency injection**: ONLY via uninitialised private fields (`= null!`). The SG detects them and generates `SetDependencies` + injects them in the `Invoker`. There is **no** `[Inject]` or `[FromServices]` for Action/Mutation.
- `DomainAction<TReturn>` has typed-error variants `DomainAction<TReturn, TError1...TError6>`. `Execute` always returns `Result<TReturn, IError>`.
- `VoidDomainAction` exposes the `Success` shorthand. Variants `VoidDomainAction<TError1...>`.
- Generates `{Type}.Invoker.g.cs` (`IDomainActionInvoker<TAction,TReturn>` in DI) and, if there are dependencies, `{Type}.SetDependencies.g.cs`.
### 4. Validation
`System.ComponentModel.DataAnnotations` attributes + those from `Pragmatic.Validation` on action properties are validated **sync** by default. For **async** validation (e.g. DB lookup) declare a `[Validator]` class implementing `IAsyncValidator<T>` for the action or mutation, in the same assembly: that is the whole opt-in, and the generator wires the call (see `pragmatic-use-foundation`). `[Validate]` only changes the default (`Async = false`, `AsyncOnly`). `[NoValidation]` skips everything, on an action and on a mutation.
### 5. Endpoint and binding
An `Endpoint<TResponse>` is the whole operation for a plain read: no action, no mutation. Same
- dependency rule as an action — uninitialised private fields, injected by the generator — and you
+ dependency rule as an action (uninitialised private fields, injected by the generator), and you
override `HandleAsync`.
```csharp
using Pragmatic.Endpoints;
using Pragmatic.Endpoints.Attributes;
using Pragmatic.Endpoints.Base;
using Pragmatic.Persistence.Repository;
using Pragmatic.Result;
using Pragmatic.Result.Http;
[Endpoint(HttpVerb.Get, "api/orders/{id}")]
public partial class GetOrderEndpoint : Endpoint<OrderDto>
{
private IReadRepository<Order> _orders = null!; // dependency: uninitialised private field
[FromRoute] public required Guid Id { get; init; }
public override async Task<Result<OrderDto>> HandleAsync(CancellationToken ct = default)
{
var order = await _orders.GetByIdAsync(Id, ct).ConfigureAwait(false);
if (order is null)
return Result<OrderDto>.Failure(NotFoundError.For<Guid>("Order", Id));
return OrderDto.FromEntity(order);
}
}
```
- ⚠️ The example shows the mechanics, not the case: one row by id, as a DTO, **is** a declared query —
- `[Query<Order, OrderDto>(Single = true)]` with `[Filter(MapTo = "PersistenceId")] public Guid Id` — which
+ ⚠️ The example shows the mechanics, not the case. One row by id, as a DTO, **is** a declared query,
+ `[Query<Order, OrderDto>(Single = true)]` with `[Filter(MapTo = "PersistenceId")] public Guid Id`, which
answers 404 when nothing matches and gets the projection, the permission and the contract without a line
of `HandleAsync` (`pragmatic-use-persistence`). Reach for `Endpoint<T>` when no query can say what is read.
`HandleAsync` is `abstract`, so the compiler asks for it. Typed-error variants exist up to six:
`Endpoint<TResponse, TError1>` … `Endpoint<TResponse, TError1..TError6>`, whose `HandleAsync` returns
the matching `Result<TResponse, TError1, …>`.
- **Which of the four to reach for** — getting this wrong is easy and shows up only when somebody reads
+ **Which of the four to reach for.** Getting this wrong is easy and shows up only when somebody reads
the code:
| | use it for |
|---|---|
| `Endpoint<T>` | reading and answering something no query can declare. |
- | `[Query]` (Persistence) | filtering, sorting, paging a list — and a get-by-id, which is a query with `Single = true` (404 when nothing matches); see `pragmatic-use-persistence`. |
+ | `[Query]` (Persistence) | filtering, sorting, paging a list, and a get-by-id, which is a query with `Single = true` (404 when nothing matches); see `pragmatic-use-persistence`. |
| `[Mutation]` | changing one entity, through validate → load/create → apply → persist → events. |
| `[DomainAction]` | an orchestrated operation that is none of the above: several entities, an external service, a side effect. |
Wrapping a read in a `DomainAction` compiles and works, and it is still wrong: it buys the whole
action pipeline for something that only needed a repository call.
- ⚠️ **"My …" reads are queries too.** A read filtered by the caller — `GET api/me`, `GET api/me/balances`
- — is not a `DomainAction` that looks the caller up by hand and passes the value to a query. Declare
+ ⚠️ **"My …" reads are queries too.** A read filtered by the caller (`GET api/me`, `GET api/me/balances`)
+ is not a `DomainAction` that looks the caller up by hand and passes the value to a query. Declare
it as a `[Query]` whose property is `[FromCurrentUser]` (or `[FromCurrentUser(nameof(Employee.Id))]`,
a member of the `[PragmaticUser]` entity): the query's invoker fills it after validation and the
permission check, and it is never a request parameter. See `pragmatic-use-persistence` → *Reading by
the caller*.
- `HttpVerb`: `Get | Post | Put | Patch | Delete | Head | Options`.
- Property binding: `[FromRoute]`, `[FromQuery]`, `[FromBody]`, `[FromForm]`, `[FromHeader]`, `[FromClaim("sub")]`. Without an attribute → implicit bind to body (**PRAG0512** info).
⚠️ **Except on an operation that carries an `IFormFile`**: the request is then `multipart/form-data`,
- there is no JSON body, and every unmarked scalar is bound from the **form** under its property name —
+ there is no JSON body, and every unmarked scalar is bound from the **form** under its property name,
so `[FromForm]` on the rest is explicit, not load-bearing. A nested object has no form-field shape and
is reported: **PRAG0552**.
- - `[FromClaim]` vs `[FromCurrentUser]`: `[FromClaim]` is binding — the endpoint reads one raw claim into
+ - `[FromClaim]` vs `[FromCurrentUser]`: `[FromClaim]` is binding; the endpoint reads one raw claim into
a public property, so an in-process caller still sets it. `[FromCurrentUser]` (on a `[Query]`, an action or a mutation) is not
binding: the endpoint never reads it, the invoker writes it on every door, and the property is
`{ get; private set; }` (**PRAG0730**). A route placeholder that names it binds nothing (**PRAG0504**).
- A `{id}` placeholder in the route without a corresponding PascalCase property → **PRAG0504**.
- Two endpoints on the same **verb and full route** (group prefix included) → **PRAG0529**, an error.
Both would be registered and routing could not choose, so the route answers **500 per request** while
the build stays clean and OpenAPI lists it once.
- `[Endpoint]` applies directly to `Mutation`/`DomainAction` too: the operation is both business logic and endpoint.
- Grouping: `[EndpointGroup("api/admin")]` on a `sealed` marker class declares the group; `[EndpointGroup<AdminGroup>]` beside `[Endpoint]` joins it. A nested group carries both. There is no `Group = typeof(…)`.
- OpenAPI: `[ApiTags(...)]`, `[ApiSummary("...")]`, `[ApiDescription("...")]`, `[AllowAnonymous]`. Versioning: `[ApiVersion("2.0")]`, `[SinceVersion("2.0")]` on properties + the `ExecuteV{n}` (action) / `HandleAsyncV{n}` (endpoint) convention, which needs `Asp.Versioning.Http`.
- Throttling/cache: `[RateLimit(Requests = 100, Window = "1m")]`, `[ResponseCache(Duration = 60)]`.
⚠️ `[ResponseCache]` in its default form (`Location.Any`) is ASP.NET's output cache. The generated
- host adds it — `AddOutputCache` and `OutputCacheStep`, after authorization — when a module declares one,
+ host adds it (`AddOutputCache` and `OutputCacheStep`, after authorization) when a module declares one,
and it keeps **anonymous answers only**: an authenticated request is never cached, whatever
`VaryByHeaders` says, and on a route that requires authentication the build says so (`PRAG0554`). For a
signed-in user's own data use
`Location = ResponseCacheLocation.Client` (`Cache-Control: private`, kept by the browser); to cache a
computed answer on the server, `[Cacheable]` on the query or action (`pragmatic-use-caching`).
### 6. Errors → HTTP
Return `Result<T, IError>` with concrete `Error` types. Default mapping to ProblemDetails:
| Error | HTTP |
|---|---|
- | `ValidationError` | 422 — the request was read and the rules refuse it |
+ | `ValidationError` | 422: the request was read and the rules refuse it |
| a body that cannot be read (malformed JSON, a string where a number goes) | 400, before any rule runs |
| `UnauthorizedError` | 401 |
| `ForbiddenError` | 403 |
| `NotFoundError` | 404 |
| `ConflictError` | 409 |
| `BusinessRuleError`, and a rule derived from it (`record WorksiteClosedError : BusinessRuleError`) | 422 |
A custom error answers its own `StatusCode`. For the **documented** contract (OpenAPI, client manifest)
the generator reads, in this order: `[HttpStatus(402)]` on the error or on a base of it, **wherever that
base lives**; then a literal `public override int StatusCode => 402;` on the error or a base in the same
project; then the first base named like a framework HTTP error; then 400. ⚠️ A base in another project is
- otherwise read by name only — a property value is not metadata, an attribute argument is, which is why
+ otherwise read by name only (a property value is not metadata, an attribute argument is), which is why
`[HttpStatus]` is the form that always crosses an assembly boundary. Declaring a status that the type's own
`StatusCode` contradicts is **PRAG0537**: the document would promise one status and the response carry the
other. On the **endpoint** class the same attribute is the success status. Maximum 6 typed error types per
endpoint (**PRAG0503**).
### 7. Processor
`[PreProcessor<T>]` / `[PostProcessor<T>]` on the endpoint for cross-cutting concerns (custom authorisation, audit). The pre-processor can short-circuit with `PreProcessorResult.Fail(error)` or `.NotFound(...)`.
### 8. Exposing package actions
- `[ExposeEndpoint<TLoginAction>(HttpVerb.Post, "login")]` goes on the `[Module]` (not on the action). Its usual source is an action folded in by `[UsePackage<T>]`, which carries no `[Endpoint]` of its own; nothing restricts it to those, and a module's own action is mapped on the host root instead of the package prefix — see `pragmatic-use-composition`. `[ExposeEndpoint<TAction, TGroup>]` maps it inside an endpoint group instead (prefix and `ConfigureGroup` options); a `TGroup` that is not an `[EndpointGroup("…")]` is PRAG1682.
+ `[ExposeEndpoint<TLoginAction>(HttpVerb.Post, "login")]` goes on the `[Module]` (not on the action). Its usual source is an action folded in by `[UsePackage<T>]`, which carries no `[Endpoint]` of its own; nothing restricts it to those, and a module's own action is mapped on the host root instead of the package prefix; see `pragmatic-use-composition`. `[ExposeEndpoint<TAction, TGroup>]` maps it inside an endpoint group instead (prefix and `ConfigureGroup` options); a `TGroup` that is not an `[EndpointGroup("…")]` is PRAG1682.
### 9. Three endpoints you do not write
An endpoint does not always come from a class of yours. Three attributes generate the whole thing,
and reaching for them is almost always better than hand-rolling the equivalent.
- **`[Autocomplete]` — search-as-you-type, from one property.**
+ **`[Autocomplete]`: search-as-you-type, from one property.**
```csharp
public partial class Guest : Entity
{
[Autocomplete] public string Email { get; private set; } = "";
[Autocomplete(Route = "api/guests/by-code", DefaultLimit = 20)] public string Code { get; private set; } = "";
}
```
- Generates `GET {entity}/autocomplete/{property}` returning `AutocompleteItem<TKey>` — the typed id
- plus the display text, serialized as **`value`**, not `label` — capped at `DefaultLimit` (10).
+ Generates `GET {entity}/autocomplete/{property}` returning `AutocompleteItem<TKey>` (the typed id
+ plus the display text, serialized as **`value`**, not `label`), capped at `DefaultLimit` (10).
`[Autocomplete<TDto>]` projects into your own DTO instead, provided it carries `[MapFrom<TEntity>]`;
the response is then the list of DTOs, not `AutocompleteItem`. Nothing else to write: no query, no
endpoint, no DTO.
⚠️ **The route is gated on a derived read permission** (`{boundary}.{entity}.read`), which you never
- declared and cannot turn off — the attribute has no knob for it. So it needs a host where somebody can
+ declared and cannot turn off; the attribute has no knob for it. So it needs a host where somebody can
hold a permission: a host that declares `[AnonymousHost]` publishes this route and answers **403** to
everyone, and the generator says so with **PRAG1692**. Either give the host an authentication method,
or write the search endpoint by hand. A host that has none at all is refused earlier, by **PRAG1695**.
ℹ️ The boundary library itself needs nothing for this: not `Identity.AspNetCore`, not anything else.
Whether anyone can authenticate is the host's fact, and the host is where it is checked.
- **`[Sse]` — a feed the server pushes.**
+ **`[Sse]`: a feed the server pushes.**
```csharp
[Endpoint(HttpVerb.Get, "api/ticks")]
[Sse(HeartbeatSeconds = 15)]
public partial class TickStream : StreamingEndpoint<Tick> { /* ... */ }
```
Turns the endpoint into `text/event-stream`. The heartbeat keeps proxies from closing an idle
- connection. Use it when the client must learn about a change it did not ask for — a board that
- refreshes itself — rather than polling.
+ connection. Use it when the client must learn about a change it did not ask for (a board that
+ refreshes itself) rather than polling.
- **`[McpTool]` — the same operation, callable by a model.**
+ **`[McpTool]`: the same operation, callable by a model.**
```csharp
[Endpoint(HttpVerb.Post, "api/booking-notes")]
[McpTool(Description = "Stores a short booking note (max 256-byte request).")]
public partial class CreateBookingNoteEndpoint : Endpoint<NoteDto> { /* ... */ }
```
Publishes the endpoint as an MCP tool. `Name` overrides the derived one; `Description` is what the
model reads to decide whether to call it, so write it for that reader.
Binding also has two attributes the list above does not mention: `[FromCookie]`, and
`[RequireAntiforgery]` on an endpoint that accepts a browser form post.
### The published contract
- A host that references `Pragmatic.Endpoints.OpenApi` publishes it at `/openapi/v1.json` in Development without being asked (with Scalar at `/scalar` when `Scalar.AspNetCore` is referenced); `app.UseApiDocumentation()` publishes it in every environment. Do not write a startup step for it. It is an OpenAPI 3.1 document built at compile time from the same attributes the endpoints come from — operation ids, tags, parameters, request and response schemas, the error codes each operation can actually answer, and `summary` taken from `[ApiSummary]` or, failing that, from the XML doc comment above the class.
+ A host that references `Pragmatic.Endpoints.OpenApi` publishes it at `/openapi/v1.json` in Development without being asked (with Scalar at `/scalar` when `Scalar.AspNetCore` is referenced); `app.UseApiDocumentation()` publishes it in every environment. Do not write a startup step for it. It is an OpenAPI 3.1 document built at compile time from the same attributes the endpoints come from: operation ids, tags, parameters, request and response schemas, the error codes each operation can actually answer, and `summary` taken from `[ApiSummary]` or, failing that, from the XML doc comment above the class.
`components.schemas` holds only what an operation can reach: the manifest carries entities for other consumers, and publishing them all put internal shapes in the public contract.
**Authentication is the one thing the generator does not decide.** Which operations require it is known at compile time (`[AllowAnonymous]`); what the requirement looks like on the wire is chosen in `Program.cs`, so whoever configures it describes it:
```csharp
app.DescribeSecurityScheme(new OpenApiSecurityScheme
{
Name = "apiKey",
Type = "apiKey",
ParameterName = "X-Api-Key",
In = "header",
});
```
- The three JWT entry points — `UseJwtAuthentication`, `UseKeycloakAuthentication`, `UseOidcAuthentication` — already do it, with `OpenApiSecurityScheme.Bearer()`, and so does `UseDevelopmentIdentity`. An application that installs an authentication of its own calls `DescribeSecurityScheme` once per way in; one that must compute the scheme registers an `IOpenApiSecuritySchemeContributor`.
+ The three JWT entry points (`UseJwtAuthentication`, `UseKeycloakAuthentication`, `UseOidcAuthentication`) already do it, with `OpenApiSecurityScheme.Bearer()`, and so does `UseDevelopmentIdentity`. An application that installs an authentication of its own calls `DescribeSecurityScheme` once per way in; one that must compute the scheme registers an `IOpenApiSecuritySchemeContributor`.
⚠️ If nothing is registered and operations do require authentication, the document **says nothing** and the application logs it. That is deliberate: a guessed scheme published as a contract fails in the integrator's application, not in yours.
- ASP.NET's own document (`AddOpenApi()` + `MapOpenApi()`) lists the generated endpoints too, beside the ones the application maps by hand: the generated registration adds the description provider, nothing to call. `EnableOpenApi = false` takes them out of both documents. ⚠️ That document cannot hold a schema nesting past 64 levels — an operation answering with an **entity** reaches it through the navigations — so such a response is published there as JSON without a schema, with a warning naming it. Answer with a DTO.
+ ASP.NET's own document (`AddOpenApi()` + `MapOpenApi()`) lists the generated endpoints too, beside the ones the application maps by hand: the generated registration adds the description provider, nothing to call. `EnableOpenApi = false` takes them out of both documents. ⚠️ That document cannot hold a schema nesting past 64 levels (an operation answering with an **entity** reaches it through the navigations), so such a response is published there as JSON without a schema, with a warning naming it. Answer with a DTO.
- ⚠️ `servers` is absent by design — a document without it means "relative to where you fetched it", which is the right answer behind a gateway or a CDN, where the application cannot see its own public address.
+ ⚠️ `servers` is absent by design: a document without it means "relative to where you fetched it", which is the right answer behind a gateway or a CDN, where the application cannot see its own public address.
## Most frequent diagnostics
| ID | Sev | Trigger | Fix |
|---|---|---|---|
| **PRAG0400** | Error | `[DomainAction]`/`[Boundary]` not `partial` | Add `partial` |
| **PRAG0401** | Error | `[DomainAction]` does not inherit `DomainAction<T>`/`VoidDomainAction` | Change base class |
| **PRAG0409** | Error | `[Mutation]` does not inherit `Mutation<TEntity>` | Change base class |
| **PRAG0410** | Error | `Mode` cannot be determined | Add `Mode = ...` or rename with a prefix |
| **PRAG0414** | Warning | Mutation property without `SetXxx()` on the entity | Add setter or `[MapIgnore]` |
| **PRAG0500** | Error | `[Endpoint]` not `partial` | Add `partial` |
| **PRAG0502** | Error | Missing route | Specify the route |
| **PRAG0504** | Warning | Route param without property | Add property or fix the route |
| **PRAG0505** | Error | Duplicate endpoint name | Set a unique `Name` |
Fix Errors before Warnings.
## Troubleshooting
- **Endpoint returns 404** — Are `[Boundary]` and `[Module]` present in the library? Is the library referenced by the host? Does `[Endpoint]` have the correct route?
+ **Endpoint returns 404.** Are `[Boundary]` and `[Module]` present in the library? Is the library referenced by the host? Does `[Endpoint]` have the correct route?
- **Dependency is `null` at runtime** — Was it declared as an uninitialised private field (`= null!`)? An initialised field is not injected. Is the type registered in DI?
+ **Dependency is `null` at runtime.** Was it declared as an uninitialised private field (`= null!`)? An initialised field is not injected. Is the type registered in DI?
- **Async validation does not fire** — Is the `[Validator]` class in the same assembly as the operation (**PRAG0215** says so when it is not)? Does the operation carry `[Validate(Async = false)]`? Is the validator's `T` the operation type itself, and not its entity or a DTO?
+ **Async validation does not fire.** Is the `[Validator]` class in the same assembly as the operation (**PRAG0215** says so when it is not)? Does the operation carry `[Validate(Async = false)]`? Is the validator's `T` the operation type itself, and not its entity or a DTO?
- **`Invoker` not resolved** — Is `Pragmatic.SourceGenerator` referenced as an analyzer with `<IncludeAssets>analyzers; build; buildtransitive</IncludeAssets>`?
+ **`Invoker` not resolved.** Is `Pragmatic.SourceGenerator` referenced as an analyzer with `<IncludeAssets>analyzers; build; buildtransitive</IncludeAssets>`?
## Build verification
```powershell
dotnet build # no PRAG = topology OK
dotnet test path\to\App.Tests --no-restore -v minimal
ls obj\Debug\net10.0\generated\Pragmatic.SourceGenerator # inspect generated invokers/endpoints
```
The `generated` folder exists only when the project sets
`<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>`; without it the generated code lives in the
compilation alone. The folder is not cleaned either: a file left there by an earlier build is not evidence
of what the current one generates.
## Examples
- Complete files in [`examples/`](examples/README.md), copied from the Invoicing example application — code
- that compiles and that `Invoicing.IntegrationTests` exercises — and kept identical to it by the gate: a
+ Complete files in [`examples/`](examples/README.md), copied from the Invoicing example application (code
+ that compiles and that `Invoicing.IntegrationTests` exercises) and kept identical to it by the gate: a
create and an update mutation carrying child mutations, two domain actions with state transitions and
typed errors, a paged and a single query, a file endpoint, and a domain error. Read them for the whole
shape of an operation; the sections above say why each piece is there.