Skip to content

Common Mistakes

These are the most common issues developers encounter when using Pragmatic.Endpoints. Each section shows the wrong approach, the correct approach, and explains why.


1. Forgetting partial on the Endpoint Class

Section titled “1. Forgetting partial on the Endpoint Class”

Wrong:

[Endpoint(HttpVerb.Get, "/orders/{id}")]
public class GetOrderEndpoint : Endpoint<OrderDto, NotFoundError>
{
[FromRoute]
public Guid Id { get; init; }
public override async Task<Result<OrderDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
// ...
}
}

Compile result: PRAG0500 error — “Endpoint class ‘GetOrderEndpoint’ must be declared as partial”.

Right:

[Endpoint(HttpVerb.Get, "/orders/{id}")]
public partial class GetOrderEndpoint : Endpoint<OrderDto, NotFoundError>
{
[FromRoute]
public Guid Id { get; init; }
public override async Task<Result<OrderDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
// ...
}
}

Why: The source generator emits SetDependencies, the handler delegate, and endpoint registration into a partial class. Without partial, the compiler cannot merge the generated code with your class.


Wrong:

[Endpoint(HttpVerb.Get, "/guests/{guestId}/reservations/{reservationId}")]
public partial class GetGuestReservationEndpoint : Endpoint<ReservationDto, NotFoundError>
{
// No properties matching route parameters!
public override async Task<Result<ReservationDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
// guestId and reservationId are never bound -- always default values
}
}

Compile result: PRAG0504 warning — “Route parameter ‘guestId’ in endpoint ‘GetGuestReservationEndpoint’ does not match any public property”. The endpoint compiles but both parameters are silently unbound at runtime (always Guid.Empty or null).

Right:

[Endpoint(HttpVerb.Get, "/guests/{guestId}/reservations/{reservationId}")]
public partial class GetGuestReservationEndpoint : Endpoint<ReservationDto, NotFoundError>
{
[FromRoute]
public Guid GuestId { get; init; }
[FromRoute]
public Guid ReservationId { get; init; }
public override async Task<Result<ReservationDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
// GuestId and ReservationId are correctly bound from the URL
}
}

Why: The SG matches route template parameters ({guestId}) to public properties by name (case-insensitive). If no property matches, the parameter is never populated. Always check PRAG0504 warnings in the build output.


Wrong:

[Endpoint(HttpVerb.Post, "/invoices/{invoiceId}/attachments")]
public partial class UploadInvoiceAttachmentEndpoint : Endpoint<AttachmentDto>
{
[FromRoute]
public Guid InvoiceId { get; init; }
// IFormFile without [FromForm] on the other fields
public IFormFile Document { get; set; } = null!;
public string Description { get; set; } = ""; // Treated as JSON body
}

Runtime result: The request expects multipart/form-data because of IFormFile, but Description is treated as a JSON body property. The generated body DTO includes Description, creating a conflict — the request cannot be both multipart and JSON.

Right:

[Endpoint(HttpVerb.Post, "/invoices/{invoiceId}/attachments")]
public partial class UploadInvoiceAttachmentEndpoint : Endpoint<AttachmentDto>
{
[FromRoute]
public Guid InvoiceId { get; init; }
[FromForm]
[MaxFileSize(25 * 1024 * 1024)]
[AllowedContentTypes("application/pdf", "image/jpeg", "image/png")]
public IFormFile Document { get; set; } = null!;
[FromForm]
public string Description { get; set; } = "";
}

Why: An endpoint is either JSON body or multipart form, not both. When using IFormFile, mark all non-route, non-query, non-header properties with [FromForm]. The SG skips body DTO generation when all properties are form-bound and emits DisableAntiforgery() automatically.


4. Calling MapPragmaticEndpoints Before AddPragmaticEndpoints

Section titled “4. Calling MapPragmaticEndpoints Before AddPragmaticEndpoints”

Wrong:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPragmaticEndpoints(); // Options singleton not registered yet!
builder.Services.AddPragmaticEndpoints(options =>
{
options.RoutePrefix = "/api";
options.EnableOpenApi = true;
});

Runtime result: InvalidOperationException at startup — the PragmaticEndpointsOptions singleton is not registered when MapPragmaticEndpoints tries to resolve it. In some cases, it silently uses default options, ignoring your RoutePrefix and EnableOpenApi settings.

Right:

var builder = WebApplication.CreateBuilder(args);
// 1. Register services BEFORE building
builder.Services.AddPragmaticEndpoints(options =>
{
options.RoutePrefix = "/api";
options.EnableOpenApi = true;
});
var app = builder.Build();
// 2. Map endpoints AFTER building
app.MapPragmaticEndpoints();
app.Run();

Why: AddPragmaticEndpoints registers the PragmaticEndpointsOptions singleton into the DI container. MapPragmaticEndpoints reads those options at startup to apply route prefixes, OpenAPI metadata, and group configuration. The service registration must happen before Build(), and endpoint mapping must happen after.


Wrong:

[DomainAction]
[Endpoint(HttpVerb.Get, "/guests/{id}")]
public partial class GetGuestAction : DomainAction<GuestDto, NotFoundError>
{
private IGuestRepository _guests = null!;
[FromRoute]
public Guid Id { get; init; }
public override async Task<Result<GuestDto, IError>> Execute(CancellationToken ct)
{
var guest = await _guests.GetByIdAsync(Id, ct);
if (guest is null) return new NotFoundError("Guest", Id);
return GuestDto.FromEntity(guest);
}
}

Runtime result: Works correctly, but the DomainAction invoker pipeline adds unnecessary overhead — dependency resolution for IDomainActionInvoker<T>, pre/post action filters, activity tracing, and logging. For a simple read-by-ID, this is wasted work.

Right:

[Endpoint(HttpVerb.Get, "/guests/{id}")]
public partial class GetGuestEndpoint : Endpoint<GuestDto, NotFoundError>
{
private IGuestRepository _guests = null!;
[FromRoute]
public Guid Id { get; init; }
public override async Task<Result<GuestDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
var guest = await _guests.GetByIdAsync(Id, ct);
if (guest is null) return new NotFoundError("Guest", Id);
return GuestDto.FromEntity(guest);
}
}

Why: DomainAction is designed for operations that benefit from the invoker pipeline: validation, authorization filters, event dispatching, and cross-cutting concerns. Simple lookups that just call a repository and return a DTO should use Endpoint<T> directly. Reserve DomainAction for commands, mutations, and queries with business logic that needs pipeline support.


6. Not Registering UseRateLimiter() Middleware

Section titled “6. Not Registering UseRateLimiter() Middleware”

Wrong:

[Endpoint(HttpVerb.Post, "/auth/login")]
[RateLimit(Requests = 5, Window = "1m")]
public partial class LoginEndpoint : Endpoint<TokenResponse> { /* ... */ }
// Program.cs
builder.Services.AddPragmaticEndpoints();
var app = builder.Build();
// Missing: app.UseRateLimiter();
app.MapPragmaticEndpoints();

Runtime result: The [RateLimit] attribute causes the SG to generate .RequireRateLimiting("__pragmatic_ratelimit_LoginEndpoint") on the endpoint and register a fixed window policy in AddPragmaticEndpoints(). However, without app.UseRateLimiter() in the middleware pipeline, ASP.NET Core never enforces the policy. All requests pass through, even after exceeding the limit.

Right:

Program.cs
builder.Services.AddPragmaticEndpoints();
var app = builder.Build();
app.UseRateLimiter(); // Required for [RateLimit] enforcement
app.MapPragmaticEndpoints();

Why: ASP.NET Core’s rate limiting is a middleware that must be explicitly added to the request pipeline. The SG generates the policy registration and endpoint metadata, but it cannot inject middleware into your pipeline. You must call app.UseRateLimiter() before MapPragmaticEndpoints().


7. Caching Authenticated Responses Without VaryBy

Section titled “7. Caching Authenticated Responses Without VaryBy”

Wrong:

[Endpoint(HttpVerb.Get, "/orders/my")]
[ResponseCache(Duration = 300)]
public partial class GetMyOrdersEndpoint : Endpoint<OrderDto[]>
{
private IOrderRepository _orders = null!;
private ICurrentUser _currentUser = null!;
public override async Task<Result<OrderDto[]>> HandleAsync(CancellationToken ct)
{
var orders = await _orders.GetByUserAsync(_currentUser.Id, ct);
return orders.Select(OrderDto.FromEntity).ToArray();
}
}

Runtime result: The response is cached for 5 minutes with the same key for all users. User A requests their orders, the response is cached, then User B hits the same endpoint and sees User A’s orders.

Right:

[Endpoint(HttpVerb.Get, "/orders/my")]
[ResponseCache(Duration = 300, VaryByHeaders = ["Authorization"])]
public partial class GetMyOrdersEndpoint : Endpoint<OrderDto[]>
{
// ...
}

Or for a per-user client-side cache only:

[Endpoint(HttpVerb.Get, "/orders/my")]
[ResponseCache(Duration = 300, Location = ResponseCacheLocation.Client)]
public partial class GetMyOrdersEndpoint : Endpoint<OrderDto[]>
{
// ...
}

Why: [ResponseCache] with Location = Any (the default) creates a shared cache across all callers. For user-specific data, either vary by Authorization header so each token gets its own cache entry, or restrict the cache to Client so only the user’s browser caches the response. Never cache user-specific responses in a shared cache without differentiation.


Wrong:

[Endpoint(HttpVerb.Post, "/orders")]
public partial class PlaceOrderEndpoint : Endpoint<OrderDto, NotFoundError, ValidationError>
{
public override async Task<Result<OrderDto, NotFoundError, ValidationError>> HandleAsync(CancellationToken ct)
{
// Only returns NotFoundError, forgot about ValidationError
var customer = await _customers.GetByIdAsync(CustomerId, ct);
if (customer is null)
return new NotFoundError("Customer", CustomerId);
// Validation logic that should return ValidationError but throws instead
if (Items.Count == 0)
throw new InvalidOperationException("Order must have at least one item");
return new OrderDto(/* ... */);
}
}

Runtime result: The InvalidOperationException bypasses the result pipeline entirely and produces a raw 500 Internal Server Error. The OpenAPI spec declares ValidationError as a possible 400 response, but it is never returned.

Right:

[Endpoint(HttpVerb.Post, "/orders")]
public partial class PlaceOrderEndpoint : Endpoint<OrderDto, NotFoundError, ValidationError>
{
public override async Task<Result<OrderDto, NotFoundError, ValidationError>> HandleAsync(CancellationToken ct)
{
if (Items.Count == 0)
return new ValidationError("Items", "Order must have at least one item.");
var customer = await _customers.GetByIdAsync(CustomerId, ct);
if (customer is null)
return new NotFoundError("Customer", CustomerId);
return new OrderDto(/* ... */);
}
}

Why: The SG auto-generates builder.Produces<TError>(statusCode) for every error type in the endpoint signature. Each error type has a default HTTP status mapping (e.g., NotFoundError = 404, ValidationError = 400). Return the typed error instead of throwing — exceptions bypass the Result pipeline and always produce 500.


9. Using typeof() Instead of Generic Attributes

Section titled “9. Using typeof() Instead of Generic Attributes”

Wrong:

[Endpoint(HttpVerb.Post, "/orders")]
[PreProcessor(typeof(ValidateCustomerProcessor))] // Non-generic version
public partial class PlaceOrderEndpoint : DomainAction<OrderDto>
{
// ...
}

Compile result: Works, but PRAG0508 cannot validate the type constraint at the attribute level. If ValidateCustomerProcessor does not implement IEndpointPreProcessor, the error only surfaces at runtime.

Right:

[Endpoint(HttpVerb.Post, "/orders")]
[PreProcessor<ValidateCustomerProcessor>] // Generic version
public partial class PlaceOrderEndpoint : DomainAction<OrderDto>
{
// ...
}

Why: The generic [PreProcessor<T>] has a where T : IEndpointPreProcessor constraint. The compiler verifies at build time that the processor implements the required interface. The non-generic [PreProcessor(typeof(...))] version exists only for edge cases where the type is not known at compile time. Always prefer the generic form for compile-time safety and IntelliSense.


10. Applying File Validation Attributes to Non-File Properties

Section titled “10. Applying File Validation Attributes to Non-File Properties”

Wrong:

[Endpoint(HttpVerb.Post, "/documents")]
public partial class UploadDocumentEndpoint : Endpoint<DocumentDto>
{
[MaxFileSize(10 * 1024 * 1024)]
[AllowedContentTypes("application/pdf")]
public string DocumentUrl { get; set; } = ""; // string, not IFormFile!
}

Runtime result: The [MaxFileSize] and [AllowedContentTypes] attributes are silently ignored. No file size or content type validation occurs. The SG only generates validation checks for IFormFile or IFormFileCollection properties.

Right:

[Endpoint(HttpVerb.Post, "/documents")]
public partial class UploadDocumentEndpoint : Endpoint<DocumentDto>
{
[FromForm]
[MaxFileSize(10 * 1024 * 1024)]
[AllowedContentTypes("application/pdf")]
public IFormFile Document { get; set; } = null!;
}

Why: [MaxFileSize] and [AllowedContentTypes] are HTTP-boundary validations specifically designed for file uploads. The SG generates checks that call IFormFile.Length and IFormFile.ContentType before HandleAsync. On a string property, there is no file to check — the attributes have no effect and produce no warnings.


11. Throwing Exceptions Instead of Returning Error Types

Section titled “11. Throwing Exceptions Instead of Returning Error Types”

Wrong:

[Endpoint(HttpVerb.Get, "/invoices/{id}")]
public partial class GetInvoiceEndpoint : Endpoint<InvoiceDto, NotFoundError>
{
private IInvoiceRepository _invoices = null!;
[FromRoute]
public Guid Id { get; init; }
public override async Task<Result<InvoiceDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
var invoice = await _invoices.GetByIdAsync(Id, ct);
if (invoice is null)
throw new KeyNotFoundException($"Invoice {Id} not found");
return InvoiceDto.FromEntity(invoice);
}
}

Runtime result: The exception propagates as an unhandled error, producing a 500 Internal Server Error with a stack trace (in Development) or a generic error page (in Production). The NotFoundError in the endpoint signature is never used. The OpenAPI spec declares a 404 response that is never returned.

Right:

[Endpoint(HttpVerb.Get, "/invoices/{id}")]
public partial class GetInvoiceEndpoint : Endpoint<InvoiceDto, NotFoundError>
{
private IInvoiceRepository _invoices = null!;
[FromRoute]
public Guid Id { get; init; }
public override async Task<Result<InvoiceDto, NotFoundError>> HandleAsync(CancellationToken ct)
{
var invoice = await _invoices.GetByIdAsync(Id, ct);
if (invoice is null)
return new NotFoundError("Invoice", Id);
return InvoiceDto.FromEntity(invoice);
}
}

Why: Pragmatic uses the Result pattern for all business logic outcomes. The generated handler maps each error type to an HTTP status code (NotFoundError = 404, ValidationError = 400, etc.) and serializes the error body. Exceptions bypass the pipeline entirely, lose type information, and always produce 500. Reserve exceptions for truly unexpected failures (e.g., database connection lost), not for expected business conditions.


12. Using [FromBody] Explicitly on Every Property

Section titled “12. Using [FromBody] Explicitly on Every Property”

Wrong:

[Endpoint(HttpVerb.Post, "/guests")]
public partial class CreateGuestEndpoint : Endpoint<GuestDto>
{
[FromBody]
public required string FirstName { get; init; }
[FromBody]
public required string LastName { get; init; }
[FromBody]
public required string Email { get; init; }
[FromBody]
public string? Phone { get; init; }
}

Compile result: Works, but the [FromBody] attributes are redundant. The SG already treats all properties that are not [FromRoute], [FromQuery], [FromHeader], [FromForm], or [FromClaim] as body properties.

Right:

[Endpoint(HttpVerb.Post, "/guests")]
public partial class CreateGuestEndpoint : Endpoint<GuestDto>
{
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required string Email { get; init; }
public string? Phone { get; init; }
}

Or, when mixing binding sources, use [FromBody] only on the ambiguous ones:

[Endpoint(HttpVerb.Post, "/guests/{groupId}")]
public partial class CreateGuestEndpoint : Endpoint<GuestDto>
{
[FromRoute]
public Guid GroupId { get; init; }
[FromHeader(Name = "X-Correlation-Id")]
public string? CorrelationId { get; init; }
// These are auto-detected as body -- no attribute needed
public required string FirstName { get; init; }
public required string LastName { get; init; }
public required string Email { get; init; }
}

Why: The SG auto-infers body properties by exclusion: anything not bound to route, query, header, form, or claim is part of the request body. Adding [FromBody] everywhere adds noise without changing behavior. Note that PRAG0512 (info-level diagnostic) fires when 5 or more properties implicitly bind to the body, nudging you to confirm the binding is intentional. In that case, adding [FromBody] to one property silences the diagnostic for clarity.


MistakeDiagnostic / Symptom
Missing partialPRAG0500 compile error
Route parameter unmatchedPRAG0504 warning, parameter always default
Mixed body + formRequest fails or fields missing at runtime
Map before AddInvalidOperationException or ignored options
DomainAction for simple readsWorks but unnecessary pipeline overhead
Missing UseRateLimiter()Rate limits silently unenforced
Cache without VaryByCross-user data leakage
Unhandled error type500 instead of typed HTTP status
typeof() in attributeNo compile-time constraint checking
File attributes on non-fileSilently ignored, no validation
Throwing exceptions500 instead of Result-mapped status
Redundant [FromBody]Harmless noise, consider removing