Skip to content

Creating endpoints

Turn a handler into a minimal API endpoint with a Map attribute, and register the lot with MapXxxEndpoints.

3 min read

Any Immediate.Handlers handler becomes an endpoint by adding a Map attribute next to [Handler]:

GetUsers.cs
[Handler]
[MapGet("/users")]
public static partial class GetUsers
{
	public sealed record Query;

	private static ValueTask<IEnumerable<User>> HandleAsync(
		Query _,
		UsersService usersService,
		CancellationToken token
	)
	{
		return usersService.GetUsersAsync(token);
	}
}

The class must also carry [Handler]. Without it the generator has no Handler type to resolve from DI, and IAPI0001 is reported as an error, with a code fix that adds the attribute.

The verb attributes

Five attributes cover the common HTTP verbs:

AttributeVerb
[MapGet]GET
[MapPost]POST
[MapPut]PUT
[MapPatch]PATCH
[MapDelete]DELETE

For anything else — HEAD, OPTIONS, a WebDAV verb, a custom one — use [MapMethod], which takes the verb first and the routes after:

HeadHealth.cs
[Handler]
[MapMethod("HEAD", "/health")]
public static partial class HeadHealth
{
	public sealed record Query;

	private static ValueTask<bool> HandleAsync(Query _, CancellationToken token) =>
		ValueTask.FromResult(true);
}

[MapMethod] generates a call to MapMethods(app, route, ["HEAD"], …) rather than one of the verb-specific helpers.

Multiple routes

Every Map attribute takes params string[] routes, so one handler can answer on several paths. The generator emits one MapGet/MapPost/… call per route, all pointing at the same handler:

GetUsers.cs
[Handler]
[MapGet("/api/users", "/v1/users")]
public static partial class GetUsers
{
	// ...
}

[MapMethod] works the same way after the verb: [MapMethod("HEAD", "/api/health", "/health")].

Route parameters and constraints

Routes are plain ASP.NET Core route templates, so parameters and constraints such as {id:int} work as usual. Route values are bound onto properties of your request type:

GetUser.cs
[Handler]
[MapGet("/users/{id:int}")]
public static partial class GetUser
{
	public sealed record Query
	{
		public required int Id { get; init; }
	}

	private static ValueTask<User?> HandleAsync(
		Query query,
		UsersService usersService,
		CancellationToken token
	)
	{
		return usersService.GetUserAsync(query.Id, token);
	}
}

A GET request binds as [AsParameters], so Id is populated from the route value id. For verbs that bind the body, or for mixed route-plus-body requests, read Binding request data.

The route strings are marked with [StringSyntax("Route")], so the IDE offers route-template completion and validation inside the attribute.

Registering the endpoints

In Program.cs, call app.MapXxxEndpoints(), where Xxx is the assembly identifier — by default the assembly name with ., - and spaces removed:

Program.cs
var app = builder.Build();

app.MapUsersApiEndpoints();

app.Run();

The generated method has this signature:

public static RouteGroupBuilder MapUsersApiEndpoints(
	this IEndpointRouteBuilder app,
	[StringSyntax("Route")] string prefix = "",
	params ReadOnlySpan<string> tags
);
  • prefix wraps every registered endpoint in a MapGroup(prefix). Pass "/api" to mount the whole assembly’s endpoints under /api.
  • tags filters which endpoints are registered — see Tagged registration.
  • The return value is the RouteGroupBuilder for that prefix group, so you can apply conventions across everything at once:
Program.cs
app.MapUsersApiEndpoints("/api")
	.RequireAuthorization()
	.WithTags("Users");

One map attribute per class

The generator reads the first Map attribute it finds on the class. Applying two different verb attributes to the same handler does not produce two endpoints — use one attribute per handler class, and write a second handler for a second verb.

Two handlers that resolve to the same verb and route within the same route group are reported by IAPI0009, a warning raised at the end of the compilation and pointing at every conflicting attribute.