Endpoint Groups
The Problem
Section titled “The Problem”As an API grows, endpoints multiply. You end up with dozens of endpoints sharing the same route prefix (/api/v1/orders/...), the same authorization requirements, the same OpenAPI tags, and the same rate limiting policies. Without a grouping mechanism, this shared configuration is repeated on every endpoint, violating DRY and making global changes painful.
Endpoint groups solve this by letting you declare shared configuration once and apply it to a set of related endpoints. Groups define route prefixes, OpenAPI tags, authorization, versioning, and other cross-cutting concerns at the group level, with individual endpoints inheriting or overriding as needed.
[EndpointGroup] Attribute
Section titled “[EndpointGroup] Attribute”The [EndpointGroup] attribute is applied to a static class to define a group. Endpoints reference the group via the Group property on [Endpoint].
[EndpointGroup("/api/v1/orders", Tag = "Orders")]public static class OrdersGroup { }
[Endpoint(HttpVerb.Get, "/", Group = typeof(OrdersGroup))]public partial class GetOrders : Endpoint<OrderListDto> { }
[Endpoint(HttpVerb.Post, "/", Group = typeof(OrdersGroup))]public partial class PlaceOrder : DomainAction<OrderId> { }
[Endpoint(HttpVerb.Get, "/{id}", Group = typeof(OrdersGroup))]public partial class GetOrder : Endpoint<OrderDto, NotFoundError>{ public required Guid Id { get; init; }}These three endpoints resolve to:
GET /api/v1/orders/POST /api/v1/orders/GET /api/v1/orders/{id}
Attribute Properties
Section titled “Attribute Properties”| Property | Type | Description |
|---|---|---|
RoutePrefix | string | Route prefix prepended to all endpoint routes (constructor parameter) |
Tag | string? | OpenAPI tag applied to all endpoints in the group |
Version | string? | API version metadata for all endpoints in the group |
Parent | Type? | Parent group type for nested groups |
Route Composition
Section titled “Route Composition”The endpoint’s route is relative to the group’s RoutePrefix. The SG combines them at generation time:
| Group RoutePrefix | Endpoint Route | Final Route |
|---|---|---|
/api/v1/orders | / | /api/v1/orders/ |
/api/v1/orders | /{id} | /api/v1/orders/{id} |
/api/v1/orders | /{id}/items | /api/v1/orders/{id}/items |
If the global RoutePrefix is also set in PragmaticEndpointsOptions, it is prepended to the group prefix. For example, with options.RoutePrefix = "/api" and a group prefix /v1/orders, the final route becomes /api/v1/orders/{endpoint-route}.
How Groups Work Under the Hood
Section titled “How Groups Work Under the Hood”The SG groups endpoints by their Group type during registration. In the generated MapPragmaticEndpoints() method, grouped endpoints are registered under a MapGroup() call:
public static IEndpointRouteBuilder MapPragmaticEndpoints(this IEndpointRouteBuilder endpoints){ var pragmaticOptions = endpoints.ServiceProvider .GetService<PragmaticEndpointsOptions>(); var prefix = pragmaticOptions?.RoutePrefix ?? ""; var root = string.IsNullOrEmpty(prefix) ? endpoints : endpoints.MapGroup(prefix);
// Ungrouped endpoints UnGroupedEndpoint.MapEndpoint(root);
// Group: OrdersGroup var ordersGroup = root.MapGroup("/api/v1/orders");
GetOrders.MapEndpoint(ordersGroup); PlaceOrder.MapEndpoint(ordersGroup); GetOrder.MapEndpoint(ordersGroup);
return endpoints;}The group variable name is derived from the group class name: the Group suffix (if present) is removed, the first letter is lowercased, and Group is appended. So OrdersGroup becomes ordersGroup, ProductsApiGroup becomes productsApiGroup.
If the group has a Version property set, the SG adds version metadata to the group:
ordersGroup.WithMetadata(new Microsoft.AspNetCore.Mvc.ApiVersionAttribute("2.0"));Nested Groups
Section titled “Nested Groups”Groups can be nested using the Parent property. The route prefixes are concatenated:
// Top-level group[EndpointGroup("/api")]public static class ApiGroup { }
// Nested under ApiGroup[EndpointGroup("/v1/orders", Tag = "Orders", Parent = typeof(ApiGroup))]public static class OrdersGroup { }
// Nested under ApiGroup[EndpointGroup("/v1/products", Tag = "Products", Parent = typeof(ApiGroup))]public static class ProductsGroup { }
// Endpoint in OrdersGroup[Endpoint(HttpVerb.Get, "/{id}", Group = typeof(OrdersGroup))]public partial class GetOrder : Endpoint<OrderDto, NotFoundError>{ public required Guid Id { get; init; }}The SG walks up the parent chain to compute the full route prefix. In this case:
ApiGroup.RoutePrefix=/apiOrdersGroup.RoutePrefix=/v1/orders- Full prefix =
/api/v1/orders GetOrderroute =/{id}- Final route =
/api/v1/orders/{id}
The prefix computation trims leading slashes from each segment and joins them with /:
// SG logic (simplified)var prefixes = new List<string>();var current = group;while (current is not null){ if (!string.IsNullOrEmpty(current.RoutePrefix)) prefixes.Insert(0, current.RoutePrefix.TrimStart('/')); current = current.Parent;}return "/" + string.Join("/", prefixes);Nesting Use Cases
Section titled “Nesting Use Cases”Nesting is useful when you have a common API prefix and want to organize sub-areas:
[EndpointGroup("/api/v2")]public static class ApiV2Group { }
[EndpointGroup("/billing", Tag = "Billing", Parent = typeof(ApiV2Group))]public static class BillingGroup { }
[EndpointGroup("/shipping", Tag = "Shipping", Parent = typeof(ApiV2Group))]public static class ShippingGroup { }
// /api/v2/billing/invoices[Endpoint(HttpVerb.Get, "/invoices", Group = typeof(BillingGroup))]public partial class GetInvoices : Endpoint<InvoiceListDto> { }
// /api/v2/shipping/tracking/{id}[Endpoint(HttpVerb.Get, "/tracking/{id}", Group = typeof(ShippingGroup))]public partial class GetTracking : Endpoint<TrackingDto, NotFoundError> { }Programmatic Configuration: ConfigureGroup
Section titled “Programmatic Configuration: ConfigureGroup”While [EndpointGroup] handles declarative configuration (route prefix, tag, version), ConfigureGroup() allows programmatic configuration at startup for aspects that benefit from runtime control. The SG reads the [EndpointGroup] attribute at compile-time for route structure, then applies ConfigureGroup() options at runtime in MapPragmaticEndpoints() for auth, rate limiting, and tags.
services.AddPragmaticEndpoints(options =>{ options.ConfigureGroup("Orders", group => { group.RoutePrefix = "/api/v1/orders"; group.Tags.Add("Orders"); group.RequireAuthorization = true; group.AuthorizationPolicy = "OrdersPolicy"; group.RateLimitPolicy = "standard"; group.ResponseCacheDuration = 60; });});EndpointGroupOptions Properties
Section titled “EndpointGroupOptions Properties”| Property | Type | Default | Description |
|---|---|---|---|
RoutePrefix | string? | null | Route prefix for the group |
Tags | List<string> | [] | OpenAPI tags |
RequireAuthorization | bool | false | Whether authorization is required |
AuthorizationPolicy | string? | null | Named authorization policy |
RequiredPermissions | List<string> | [] | Permissions required for all endpoints (AND logic) |
Version | string? | null | API version |
RateLimitPolicy | string? | null | Named rate limit policy |
ResponseCacheDuration | int? | null | Default cache duration in seconds |
EnableCors | bool | false | Enable CORS |
CorsPolicy | string? | null | Named CORS policy |
When to Use Programmatic Configuration
Section titled “When to Use Programmatic Configuration”The [EndpointGroup] attribute is limited to compile-time constants. Programmatic configuration is better for:
- Authorization policies that reference dynamic policy names or require complex setup
- Rate limit policies that are shared across groups
- CORS configuration that varies by environment
- Cache durations that differ between development and production
You can combine both approaches — use [EndpointGroup] for route prefix and tag (compile-time), and ConfigureGroup for runtime concerns:
[EndpointGroup("/api/v1/orders", Tag = "Orders")]public static class OrdersGroup { }
// In startup:options.ConfigureGroup("Orders", group =>{ group.RequireAuthorization = true; group.AuthorizationPolicy = "OrdersPolicy"; group.RateLimitPolicy = "standard";});Endpoint Referencing
Section titled “Endpoint Referencing”Endpoints reference groups via the Group property on [Endpoint]:
[Endpoint(HttpVerb.Get, "/", Group = typeof(OrdersGroup))]public partial class GetOrders : Endpoint<OrderListDto> { }The Group property accepts a Type — the static class decorated with [EndpointGroup].
Ungrouped Endpoints
Section titled “Ungrouped Endpoints”Endpoints without a Group property are registered directly on the root route builder (with the global RoutePrefix if configured). They appear in the generated code under the “Ungrouped endpoints” comment.
Version on Groups
Section titled “Version on Groups”The Version property on [EndpointGroup] applies API version metadata to all endpoints in the group:
[EndpointGroup("/api/v2/products", Tag = "Products", Version = "2.0")]public static class ProductsV2Group { }This is metadata-only — it attaches ApiVersionAttribute metadata to the group’s MapGroup() route builder. It does not create versioned handlers (that requires convention-based versioning with HandleAsyncV{n} methods).
Complete Example
Section titled “Complete Example”// Groups.cs (one file per group, or all in one file for small APIs)
[EndpointGroup("/api")]public static class ApiGroup { }
[EndpointGroup("/v1/orders", Tag = "Orders", Parent = typeof(ApiGroup))]public static class OrdersGroup { }
[EndpointGroup("/v1/products", Tag = "Products", Parent = typeof(ApiGroup))]public static class ProductsGroup { }
[EndpointGroup("/admin", Tag = "Admin")]public static class AdminGroup { }// Programmatic configurationservices.AddPragmaticEndpoints(options =>{ options.ConfigureGroup("Orders", group => { group.RequireAuthorization = true; group.RateLimitPolicy = "api-standard"; });
options.ConfigureGroup("Admin", group => { group.RequireAuthorization = true; group.AuthorizationPolicy = "AdminOnly"; group.RequiredPermissions.Add("admin:access"); });});// Endpoints reference their groups
// GET /api/v1/orders[Endpoint(HttpVerb.Get, "/", Group = typeof(OrdersGroup))]public partial class GetOrders : Endpoint<OrderListDto> { }
// POST /api/v1/orders[Endpoint(HttpVerb.Post, "/", Group = typeof(OrdersGroup))]public partial class PlaceOrder : DomainAction<OrderId> { }
// GET /api/v1/orders/{id}[Endpoint(HttpVerb.Get, "/{id}", Group = typeof(OrdersGroup))]public partial class GetOrder : Endpoint<OrderDto, NotFoundError>{ public required Guid Id { get; init; }}
// GET /api/v1/products[Endpoint(HttpVerb.Get, "/", Group = typeof(ProductsGroup))]public partial class GetProducts : Endpoint<ProductListDto> { }
// GET /admin/dashboard[Endpoint(HttpVerb.Get, "/dashboard", Group = typeof(AdminGroup))]public partial class GetAdminDashboard : Endpoint<DashboardDto> { }The generated MapPragmaticEndpoints() creates MapGroup calls for OrdersGroup (at /api/v1/orders) and ProductsGroup (at /api/v1/products), each with their endpoints registered under the group builder. The AdminGroup gets its own MapGroup at /admin.
- Group class is a marker: The static class with
[EndpointGroup]is just a type marker. It can be empty (no members) or contain aConfiguremethod (convention for future use). The SG reads the attribute, not the class body. - One group per class:
[EndpointGroup]hasAllowMultiple = falseandInherited = false. Each static class represents exactly one group. - Route prefix is required: The
RoutePrefixis a constructor parameter of[EndpointGroup], so it cannot be omitted. If you want a group with no prefix (just for shared config), use an empty string:[EndpointGroup("")]. - Tag inheritance: When a group sets a
Tag, it provides a default tag for OpenAPI documentation. Individual endpoints can override this with their own[Endpoint]tags configuration. - Alphabetical ordering: Within each group, endpoints are sorted by route in the generated registration code. This ensures deterministic output across builds.
- Global prefix stacking: The global
PragmaticEndpointsOptions.RoutePrefix, group prefix, and endpoint route all stack. Withoptions.RoutePrefix = "/api", group prefix/v1/orders, and endpoint route/{id}, the final route is/api/v1/orders/{id}.