---
title: 'Cratis Arc endpoint groups: separate internal APIs'
editUrl: https://github.com/Cratis/Arc/edit/main/Documentation/backend/csharp/endpoint-groups.md
description: Serve internal Cratis Arc commands and queries on assigned local ports, configure group route prefixes, and generate proxies for each endpoint group.
lastUpdated: 2026-11-05T00:00:00.000Z
---


Cratis Arc endpoint groups serve selected commands and queries only on assigned local ports. Use a group when an operations tool or another internal service needs endpoints that should not be routed through the public API. Tag the commands, read models, or query methods, then configure both the server listener and the group's port assignment.

Groups control where an endpoint is reachable, not who may call it. Keep [authorization](/arc/backend/csharp/authorizing-commands-and-queries/) on protected commands and queries, and configure the network to keep group ports private. Use [capability hosting](/arc/backend/csharp/capability-hosting/) to require a verified caller on a dedicated group port.

:::caution
The port is the boundary only if the network makes it one. Never expose or forward a group port to the public network, and never let a reverse proxy route public traffic to it. Arc decides by the local port of the connection, which is only as trustworthy as the network in front of it.
:::

## Tag commands and read models

Put `[EndpointGroup]` on a command, or on a read model to place all of its queries in the group:

```csharp
using Cratis.Arc;
using Cratis.Arc.Commands.ModelBound;

[Command]
[EndpointGroup("internal")]
public record RebuildSearchIndex(IndexName Index)
{
    public IndexRebuildStarted Handle() => new(Index);
}
```

To place every command and read model in an assembly in one group, put the attribute on the assembly:

```csharp
[assembly: Cratis.Arc.EndpointGroup("internal")]
```

An attribute on a type wins over one on its assembly. Commands and read models with neither attribute belong to the default group.

## Put one query in a group

Put `[EndpointGroup]` on a query method when the other queries on the read model should stay public. This illustrative read model uses fixed values to show the boundary:

```csharp
using Cratis.Arc;
using Cratis.Arc.Queries.ModelBound;

[ReadModel]
public record InventorySummary(int InStock)
{
    public static InventorySummary Current() => new(42);

    [EndpointGroup("internal")]
    public static InventorySummary Reserved() => new(7);
}
```

`Current` stays in the default group; `Reserved` belongs to `internal`. A method's attribute wins over its type, which wins over its assembly. You cannot use a method attribute to move a query back to the default group: group names must be non-empty.

Method-level grouping applies only to query methods and MVC controller actions. An attribute on another method has no effect.

## Assign a port to the group

The group assignment does not create a server listener. Configure the ASP.NET Core host to listen on both the public and internal ports, then assign the internal port in Arc's options:

```csharp
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://*:5000", "http://*:5001");

builder.AddCratisArc(options => options.EndpointGroups.Assign("internal", 5001));

var app = builder.Build();
app.UseCratisArc();
app.Run();
```

The same assignment can come from configuration under `Cratis:Arc`:

```json
{
    "Cratis": {
        "Arc": {
            "EndpointGroups": {
                "Ports": {
                    "internal": [5001]
                }
            }
        }
    }
}
```

## Give a group its own route prefix

By default, grouped commands and queries use the global generated API prefix. Set a group's prefix when internal callers need a separate route layout. In the host setup above, replace the `AddCratisArc` call with:

```csharp
builder.AddCratisArc(options => options.EndpointGroups
    .Assign("internal", 5001)
    .SetRoutePrefix("internal", "internal-api"));
```

The same settings can come from configuration:

```json
{
    "Cratis": {
        "Arc": {
            "EndpointGroups": {
                "Ports": {
                    "internal": [5001]
                },
                "RoutePrefixes": {
                    "internal": "internal-api"
                }
            }
        }
    }
}
```

The override applies only to Arc-generated command and query routes. It does not change custom `[Route]` templates, controller routes or the observable-query hub routes under `/.cratis/queries`. Groups without an override, and the default group, keep the global prefix. The prefix does not replace the port boundary.

When generating proxies for this group, set `CratisProxiesApiPrefix` to the same prefix (or pass `--api-prefix=internal-api` to the generator).

## What each port serves

| Request arrives on | Default group | A named group |
| --- | --- | --- |
| A port not assigned to any group | Served | Not found (404) |
| A port assigned to the group | Not found (404), unless `ServeDefaultGroupOnGroupPorts` is set | Served |

The decision is made while routing chooses an endpoint, from the local port of the connection. The `Host` header plays no part, so a request on the public port cannot reach a grouped endpoint by naming the internal host or port.

The default group is not served on group ports. Set `options.EndpointGroups.ServeDefaultGroupOnGroupPorts = true` if internal callers should also reach the default endpoints on the group port.

MVC controllers follow the same rule: put `[EndpointGroup]` on the controller class or its assembly, and every action of the controller is served only on the ports of that group. An attribute on an individual action overrides the controller's group without changing its siblings. A grouped controller or action without an assigned port stops startup; assign a port before starting the host.

Endpoints you map yourself, such as minimal API endpoints, are not affected by endpoint groups.

## Observable queries

The observable-query hub routes (WebSocket and server-sent events under `/.cratis/queries`) are reachable on every port. A subscription resolves only the queries whose group is served on the port the request arrived on, so a client on the public port cannot subscribe to a grouped query.

A server-sent events connection is bound to the groups of the port it was opened on. Subscribe and unsubscribe requests for that connection must arrive on a port serving the same groups; from any other port they are answered as though the connection did not exist.

## Startup checks

Arc refuses to start rather than expose a group by mistake:

- A command, read model query, controller or controller action is in a group that has no port assigned.
- Every port the server listens on is assigned to a group, which would make a group port the public port.
- The application runs on the Arc.Core HTTP listener host, which cannot restrict an endpoint to a port. Endpoint groups need the ASP.NET Core host.

## Discovery and proxies

Grouped commands, queries and controllers are left out of [OpenAPI](/arc/backend/csharp/open-api/) and [introspection](/arc/backend/csharp/introspection/).

The proxy generator emits the default group only. Model-bound query proxies follow each query method's group, so selecting a group does not pull in its public siblings. To generate the proxies of a named group instead, set the `CratisProxiesEndpointGroup` MSBuild property in the project that generates them. Include `CratisProxiesApiPrefix` when you configured a group prefix:

```xml
<PropertyGroup>
    <CratisProxiesEndpointGroup>internal</CratisProxiesEndpointGroup>
    <CratisProxiesApiPrefix>internal-api</CratisProxiesApiPrefix>
</PropertyGroup>
```

When you run the generator yourself, pass `--endpoint-group=<name>` instead. Continue with [proxy generation](/arc/backend/csharp/proxy-generation/) to configure the generation step for the client that will call this group.
