Skip to content

Query and Mutation Endpoints

Guide to using [Query<T,R>] and [Mutation<T>] with [Endpoint] for data-driven HTTP endpoints.

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.

PatternAttributeHTTP MethodPipeline
Query[Query<TEntity, TResult>]GETFilter, sort, page, project
Create[Mutation(Mode = Create)]POSTValidate, create entity, persist
Update[Mutation(Mode = Update)]PUTLoad entity, validate, apply, persist
Delete[Mutation(Mode = Delete)]DELETELoad entity, soft-delete, persist
Restore[Mutation(Mode = Restore)]POSTLoad soft-deleted entity, restore, persist

A query endpoint combines [Query<TEntity, TResult>] with [Endpoint]. The generator produces a GET handler that:

  1. Resolves the correct DbContext from the entity’s boundary
  2. Applies filter properties as WHERE clauses
  3. Applies sort properties as ORDER BY clauses
  4. Pages the results (when Page/PageSize properties exist)
  5. Projects to TResult using 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.

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 target
public bool? Active { get; init; }

Use MapTo when the query property name differs from the entity property name.

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}

[Sort(DefaultDirection = 0)] // Default ascending
public SortDirection? NameSort { get; init; }
[Sort] // No default -- only applied if provided
public SortDirection? StarRatingSort { get; init; }

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 { ... }

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.

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

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

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

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.

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!;
// ...
}

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
}

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

  1. Use Query for reads, Mutation for writes — do not mix both in the same endpoint class
  2. Make filter properties nullable — non-null filters are always applied
  3. Use MapTo for name mismatches — keeps query API clean while mapping to entity properties
  4. Override ApplyAsync only when needed — simple CRUD works without override
  5. Use [Required] on mutation inputs — validation runs before entity loading
  6. Combine with [RequirePermission] — permission checks run before any database access
  7. Use endpoint groups for related mutations — consistent route prefixes and OpenAPI tags