Query and Mutation Endpoints
Guide to using [Query<T,R>] and [Mutation<T>] with [Endpoint] for data-driven HTTP endpoints.
Overview
Section titled “Overview”Query and Mutation endpoints combine Pragmatic.Persistence operations with HTTP exposure. Instead of writing boilerplate for loading, filtering, sorting, paging, and persisting, you declare the shape of the operation and the source generator wires everything together.
| Pattern | Attribute | HTTP Method | Pipeline |
|---|---|---|---|
| Query | [Query<TEntity, TResult>] | GET | Filter, sort, page, project |
| Create | [Mutation(Mode = Create)] | POST | Validate, create entity, persist |
| Update | [Mutation(Mode = Update)] | PUT | Load entity, validate, apply, persist |
| Delete | [Mutation(Mode = Delete)] | DELETE | Load entity, soft-delete, persist |
| Restore | [Mutation(Mode = Restore)] | POST | Load soft-deleted entity, restore, persist |
Query Endpoints
Section titled “Query Endpoints”Basic Query
Section titled “Basic Query”A query endpoint combines [Query<TEntity, TResult>] with [Endpoint]. The generator produces a GET handler that:
- Resolves the correct
DbContextfrom the entity’s boundary - Applies filter properties as WHERE clauses
- Applies sort properties as ORDER BY clauses
- Pages the results (when
Page/PageSizeproperties exist) - Projects to
TResultusing the generated mapping
[Query<Amenity, AmenityDto>][Endpoint(HttpVerb.Get, "api/v1/amenities/search")][RequirePermission(CatalogPermissions.Amenity.Read)]public partial class SearchAmenitiesQuery{ [Filter(Operator = FilterOperator.Contains)] public string? Name { get; init; }
[Filter] public AmenityCategory? Category { get; init; }
[Sort(DefaultDirection = 0)] public SortDirection? NameSort { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 50;}Request: GET /api/v1/amenities/search?name=pool&category=Recreation&page=1&pageSize=20
All filter and sort properties become query string parameters. Nullable filter properties are only applied when a value is provided.
Filter Operators
Section titled “Filter Operators”The [Filter] attribute supports various operators via the Operator property:
[Filter] // Equals (default)public string? City { get; init; }
[Filter(Operator = FilterOperator.Contains)] // LIKE '%value%'public string? Name { get; init; }
[Filter(Operator = FilterOperator.GreaterOrEqual, MapTo = "StarRating")]public int? MinStarRating { get; init; } // Maps to a different entity property
[Filter(MapTo = "IsActive")] // Renames the filter targetpublic bool? Active { get; init; }Use MapTo when the query property name differs from the entity property name.
Complex Filters
Section titled “Complex Filters”For multi-field or nested filter logic, use [ComplexFilter]:
[ComplexFilter]public PropertyLocationFilter? Location { get; init; }Complex filters are sent as JSON in the query string: ?Location={"city":"Rome","maxStarRating":4}
Sorting
Section titled “Sorting”[Sort(DefaultDirection = 0)] // Default ascendingpublic SortDirection? NameSort { get; init; }
[Sort] // No default -- only applied if providedpublic SortDirection? StarRatingSort { get; init; }Caching
Section titled “Caching”Combine with [Cacheable] from Pragmatic.Caching for automatic response caching:
[Query<Property, PropertySummaryDto>][Endpoint(HttpVerb.Get, "api/v1/properties/search")][Cacheable(Duration = "5m", Tags = ["properties"])]public partial class SearchPropertiesQuery { ... }Mutation Endpoints
Section titled “Mutation Endpoints”Create
Section titled “Create”MutationMode.Create generates a handler that instantiates the entity, maps input properties via generated setters, and persists:
[Mutation(Mode = MutationMode.Create)][RequirePermission(CatalogPermissions.Amenity.Create)][Endpoint(HttpVerb.Post, "api/v1/amenities")]public partial class CreateAmenityMutation : Mutation<Amenity>{ public required string Name { get; init; } public AmenityCategory Category { get; init; } public string? IconName { get; init; }}Properties map to entity setters generated by the SG. No ApplyAsync override needed for simple cases.
Update
Section titled “Update”MutationMode.Update loads the entity by ID, then applies changes. Properties with values are mapped; null properties are skipped (partial update):
[Mutation(Mode = MutationMode.Update)][Endpoint(HttpVerb.Put, "api/v1/amenities/{id}")][RequirePermission(CatalogPermissions.Amenity.Update)]public partial class UpdateAmenityMutation : Mutation<Amenity>{ public required Guid Id { get; init; } public string? Name { get; init; } public AmenityCategory? Category { get; init; } public string? IconName { get; init; }}For complex update logic, override ApplyAsync:
[Endpoint(HttpVerb.Post, "/{id}/cancel", Group = typeof(ReservationsGroup))][Mutation(Mode = MutationMode.Update)]public partial class CancelReservationMutation : Mutation<Reservation, ConflictError>{ public required Guid Id { get; init; }
[Required] public required string Reason { get; init; }
public override async Task<Result<Reservation, IError>> ApplyAsync( Reservation entity, CancellationToken ct = default) { var result = entity.Cancel(Reason); if (result.IsFailure) return Result<Reservation, IError>.Failure(result.Error);
return Result<Reservation, IError>.Success(entity); }}Delete (Soft Delete)
Section titled “Delete (Soft Delete)”MutationMode.Delete loads the entity and soft-deletes it (sets IsDeleted, DeletedAt, DeletedBy):
[Mutation(Mode = MutationMode.Delete)][Endpoint(HttpVerb.Delete, "api/v1/amenities/{id}")][RequirePermission(CatalogPermissions.Amenity.Delete)]public partial class DeleteAmenityMutation : Mutation<Amenity>{ public required Guid Id { get; init; }}Restore
Section titled “Restore”MutationMode.Restore reverses a soft-delete. The generated handler bypasses soft-delete query filters to load the entity, then resets IsDeleted/DeletedAt/DeletedBy:
[Mutation(Mode = MutationMode.Restore)][Endpoint(HttpVerb.Post, "api/v1/properties/{id}/restore")][RequirePermission(CatalogPermissions.Property.Update)]public partial class RestorePropertyMutation : Mutation<Property>{ public required Guid Id { get; init; }}Mutation with Typed Errors
Section titled “Mutation with Typed Errors”Add error types to the Mutation<T> generic parameters:
public partial class CancelReservationMutation : Mutation<Reservation, ConflictError>The generated handler maps each error type to the appropriate HTTP status code and documents them in OpenAPI.
Mutation with Dependencies
Section titled “Mutation with Dependencies”Private fields are injected the same way as raw endpoints:
[Mutation(Mode = MutationMode.Update)]public partial class CancelReservationMutation : Mutation<Reservation, ConflictError>{ private IConfigurationStore _configStore = null!; private ITenantContext _tenantContext = null!; private IClock _clock = null!;
// ...}Using Endpoint Groups
Section titled “Using Endpoint Groups”Both Query and Mutation endpoints work with endpoint groups:
[EndpointGroup("/api/v1/reservations", Tag = "Reservations")]public static class ReservationsGroup;
[Endpoint(HttpVerb.Post, "/{id}/cancel", Group = typeof(ReservationsGroup))][Mutation(Mode = MutationMode.Update)]public partial class CancelReservationMutation : Mutation<Reservation, ConflictError>{ // Route: POST /api/v1/reservations/{id}/cancel}State Machine Transitions
Section titled “State Machine Transitions”Mutations that trigger entity state transitions (via [TransitionFrom] on entity enums) are validated by the mutation pipeline. If the transition is invalid, a ConflictError is returned automatically.
[Endpoint(HttpVerb.Post, "/{id}/confirm", Group = typeof(ReservationsGroup))][Mutation(Mode = MutationMode.Update)]public partial class ConfirmReservationMutation : Mutation<Reservation>{ public required Guid Id { get; init; }
public override async Task<Result<Reservation, IError>> ApplyAsync( Reservation entity, CancellationToken ct = default) { entity.Confirm(); // Validates state transition return Result<Reservation, IError>.Success(entity); }}Best Practices
Section titled “Best Practices”- Use Query for reads, Mutation for writes — do not mix both in the same endpoint class
- Make filter properties nullable — non-null filters are always applied
- Use
MapTofor name mismatches — keeps query API clean while mapping to entity properties - Override
ApplyAsynconly when needed — simple CRUD works without override - Use
[Required]on mutation inputs — validation runs before entity loading - Combine with
[RequirePermission]— permission checks run before any database access - Use endpoint groups for related mutations — consistent route prefixes and OpenAPI tags