Skip to content

Endpoint Groups

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.


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}
PropertyTypeDescription
RoutePrefixstringRoute prefix prepended to all endpoint routes (constructor parameter)
Tagstring?OpenAPI tag applied to all endpoints in the group
Versionstring?API version metadata for all endpoints in the group
ParentType?Parent group type for nested groups

The endpoint’s route is relative to the group’s RoutePrefix. The SG combines them at generation time:

Group RoutePrefixEndpoint RouteFinal 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}.


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

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:

  1. ApiGroup.RoutePrefix = /api
  2. OrdersGroup.RoutePrefix = /v1/orders
  3. Full prefix = /api/v1/orders
  4. GetOrder route = /{id}
  5. 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 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;
});
});
PropertyTypeDefaultDescription
RoutePrefixstring?nullRoute prefix for the group
TagsList<string>[]OpenAPI tags
RequireAuthorizationboolfalseWhether authorization is required
AuthorizationPolicystring?nullNamed authorization policy
RequiredPermissionsList<string>[]Permissions required for all endpoints (AND logic)
Versionstring?nullAPI version
RateLimitPolicystring?nullNamed rate limit policy
ResponseCacheDurationint?nullDefault cache duration in seconds
EnableCorsboolfalseEnable CORS
CorsPolicystring?nullNamed CORS policy

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

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].

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.


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).


// 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 configuration
services.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 a Configure method (convention for future use). The SG reads the attribute, not the class body.
  • One group per class: [EndpointGroup] has AllowMultiple = false and Inherited = false. Each static class represents exactly one group.
  • Route prefix is required: The RoutePrefix is 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. With options.RoutePrefix = "/api", group prefix /v1/orders, and endpoint route /{id}, the final route is /api/v1/orders/{id}.