The REPR Pattern in ASP.NET Core

The REPR Pattern in ASP.NET Core

6 min read··Updated ·

aspnetcoreclean-architecturedesign-patternsdotnet

The REPR pattern (Request-Endpoint-Response) gives each API operation its own class: a strongly typed request, a single-purpose endpoint function, and a strongly typed response. It replaces bloated controllers with focused endpoint files, and Minimal APIs make it lightweight to implement in ASP.NET Core.

Every controller starts small. Two actions, one dependency, easy to review. A year later it has 20 actions, 15 constructor parameters, and a merge conflict every sprint.

What Is the REPR Pattern?

REPR stands for Request-Endpoint-Response. It's a pattern for organizing web API code where each endpoint is:

  1. Request - a strongly-typed object representing the input
  2. Endpoint - a single function handling one HTTP operation
  3. Response - a strongly-typed object representing the output

Instead of grouping dozens of actions into controller classes (which violates the Single Responsibility Principle), each endpoint is its own thing.

Request-Endpoint-Response flow: an HTTP request maps to a typed request object, a single endpoint function, a command or query handler, then a typed response object and the HTTP response

This is how Minimal APIs naturally work - and it pairs perfectly with CQRS and Clean Architecture. The pattern itself was popularized by the FastEndpoints library and Ardalis (Steve Smith), but you don't need any library to use it.

The Problem With Controllers

Traditional controllers tend to grow into God classes:

[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;

    // 15 constructor parameters...
    // 20 action methods...
    // 500+ lines of code...

    [HttpPost]
    public async Task<IActionResult> PlaceOrder(PlaceOrderRequest request) { ... }

    [HttpGet("{id}")]
    public async Task<IActionResult> GetOrder(Guid id) { ... }

    [HttpPut("{id}/cancel")]
    public async Task<IActionResult> CancelOrder(Guid id) { ... }

    [HttpGet]
    public async Task<IActionResult> GetOrders([FromQuery] GetOrdersRequest request) { ... }

    // ... 16 more methods
}

Problems:

  • Violates SRP - one class handles many operations with different dependencies
  • Constructor bloat - every action's dependencies are injected, even if only one action needs them
  • Hard to navigate - finding the right action in a 500-line file is painful
  • Merge conflicts - multiple developers editing the same controller file

REPR With Minimal APIs

Each endpoint becomes a focused, single-purpose function:

The Request

public sealed record PlaceOrderRequest(
    Guid CustomerId,
    List<OrderItemRequest> Items);

public sealed record OrderItemRequest(
    Guid ProductId,
    int Quantity);

The Endpoint

public sealed class PlaceOrderEndpoint : IEndpoint
{
    public void MapEndpoint(IEndpointRouteBuilder app)
    {
        app.MapPost("/api/orders", Handle)
            .WithTags("Orders")
            .WithName("PlaceOrder")
            .Produces<PlaceOrderResponse>(StatusCodes.Status201Created)
            .ProducesProblem(StatusCodes.Status400BadRequest);
    }

    private static async Task<IResult> Handle(
        PlaceOrderRequest request,
        ICommandHandler<PlaceOrderCommand, Guid> handler,
        CancellationToken cancellationToken)
    {
        var command = new PlaceOrderCommand(request.CustomerId, request.Items);

        var result = await handler.Handle(command, cancellationToken);

        return result.IsSuccess
            ? Results.Created($"/api/orders/{result.Value}", new PlaceOrderResponse(result.Value))
            : result.ToProblemDetails();
    }
}

IEndpoint is a one-method interface that powers auto-discovery. I define it in the auto-discovery section below.

The Response

public sealed record PlaceOrderResponse(Guid OrderId);

Each endpoint lives in its own file with its own request, response, and handler reference. No shared state, no bloated constructor.

Organizing REPR Endpoints by Feature

Api/
  Endpoints/
    Orders/
      PlaceOrderEndpoint.cs
      PlaceOrderRequest.cs
      PlaceOrderResponse.cs
      GetOrderEndpoint.cs
      GetOrderResponse.cs
      CancelOrderEndpoint.cs
    Customers/
      RegisterCustomerEndpoint.cs
      RegisterCustomerRequest.cs
      GetCustomerEndpoint.cs
    Products/
      CreateProductEndpoint.cs
      GetProductsEndpoint.cs

Or colocate request/response inside the endpoint class for smaller endpoints:

public sealed class CancelOrderEndpoint : IEndpoint
{
    public sealed record CancelOrderResponse(Guid OrderId, string Status);

    public void MapEndpoint(IEndpointRouteBuilder app)
    {
        app.MapPut("/api/orders/{orderId:guid}/cancel", Handle)
            .WithTags("Orders");
    }

    private static async Task<IResult> Handle(
        Guid orderId,
        ICommandHandler<CancelOrderCommand> handler,
        CancellationToken cancellationToken)
    {
        var command = new CancelOrderCommand(orderId);

        var result = await handler.Handle(command, cancellationToken);

        return result.IsSuccess
            ? Results.Ok(new CancelOrderResponse(orderId, "Cancelled"))
            : result.ToProblemDetails();
    }
}

Auto-Discovering Endpoints

Instead of manually registering each endpoint, define a small interface and scan the assembly at startup:

public interface IEndpoint
{
    void MapEndpoint(IEndpointRouteBuilder app);
}
public static class EndpointExtensions
{
    public static void MapEndpoints(this IEndpointRouteBuilder app)
    {
        var endpointTypes = typeof(Program).Assembly
            .GetTypes()
            .Where(t => t is { IsClass: true, IsAbstract: false } &&
                        t.IsAssignableTo(typeof(IEndpoint)));

        foreach (var type in endpointTypes)
        {
            var endpoint = (IEndpoint)Activator.CreateInstance(type)!;

            endpoint.MapEndpoint(app);
        }
    }
}
// Program.cs
app.MapEndpoints();

Now every class implementing IEndpoint is automatically registered. Activator.CreateInstance works here because endpoint classes are stateless (their dependencies arrive as Handle parameters). I walk through a production-ready version of this approach (including DI registration of endpoint classes) in automatically registering Minimal APIs, and how I structure Minimal APIs covers where these files should live.

REPR + CQRS

REPR and CQRS are natural partners:

  • POST/PUT/DELETE endpoints map to Commands
  • GET endpoints map to Queries
// Write endpoint → Command
public sealed class PlaceOrderEndpoint : IEndpoint
{
    public void MapEndpoint(IEndpointRouteBuilder app) =>
        app.MapPost("/api/orders", Handle);

    private static async Task<IResult> Handle(
        PlaceOrderRequest request,
        ICommandHandler<PlaceOrderCommand, Guid> handler,
        CancellationToken ct)
    {
        var command = new PlaceOrderCommand(request.CustomerId, request.Items);
        var result = await handler.Handle(command, ct);
        return result.IsSuccess
            ? Results.Created($"/api/orders/{result.Value}", result.Value)
            : result.ToProblemDetails();
    }
}

// Read endpoint → Query
public sealed class GetOrderEndpoint : IEndpoint
{
    public void MapEndpoint(IEndpointRouteBuilder app) =>
        app.MapGet("/api/orders/{orderId:guid}", Handle);

    private static async Task<IResult> Handle(
        Guid orderId,
        IQueryHandler<GetOrderByIdQuery, OrderResponse> handler,
        CancellationToken ct)
    {
        var result = await handler.Handle(new GetOrderByIdQuery(orderId), ct);
        return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
    }
}

Each endpoint is a thin adapter between HTTP and your Application layer.

REPR vs MVC Controllers

How the two approaches compare in practice:

  • Granularity: REPR gives you one class per operation; controllers give you one class per resource with many actions.
  • Dependencies: a REPR endpoint declares only what that operation needs. A controller's constructor accumulates every action's dependencies.
  • File size: REPR endpoints stay at 20-50 lines. Controllers routinely grow into hundreds.
  • Merge conflicts: rare with one file per operation, common when a team shares one controller file.
  • Navigation: with REPR, the file name is the operation. With controllers, you scroll.
  • Testing: identical. In both styles the real logic lives in the handler, and the HTTP layer stays thin.
  • OpenAPI metadata: REPR endpoints declare Produces metadata explicitly; [ApiController] conventions infer more automatically.

The one scenario where controllers still earn their keep: if you depend heavily on MVC-specific features (filters with complex ordering, model binding conventions, view results), migrating to REPR is a bigger lift than the benefit justifies.

Do You Need a Library?

Three ways to get REPR in practice:

  • Plain Minimal APIs (what this article shows): zero dependencies, full control, you own the ~30 lines of auto-discovery code.
  • FastEndpoints: a mature library built entirely around REPR, with base classes per endpoint, built-in validation, and its own request pipeline. Great if you want conventions decided for you; the cost is a framework layer between you and ASP.NET Core.
  • Single-action controllers: one controller class per operation. Works if your team must stay on MVC, but you keep the controller ceremony without gaining much.

I default to plain Minimal APIs. The pattern is simple enough that a library is optional, and staying on the framework's primitives means every ASP.NET Core feature (filters, rate limiting, OpenAPI) works without adapter layers.

Summary

The REPR pattern treats each API operation as an independent unit: one request, one endpoint function, one response.

Combined with Minimal APIs and CQRS, it gives you focused, maintainable endpoint files that map cleanly to your Application layer commands and queries.

Stop writing 500-line controllers. One endpoint, one file, one responsibility.


Frequently Asked Questions

What does REPR stand for?

Request-Endpoint-Response. Each API operation gets a strongly typed request object, a single-purpose endpoint function, and a strongly typed response object, instead of living as one action among twenty in a controller.

Is the REPR pattern the same as Minimal APIs?

No, but they fit naturally. REPR is an organizational pattern (one endpoint, one file, one responsibility). Minimal APIs are the ASP.NET Core feature that makes implementing it lightweight. You can also follow REPR with the FastEndpoints library or even single-action controllers.

What is wrong with regular MVC controllers?

Nothing inherently, but controllers accumulate actions and dependencies over time. A 500-line controller with 15 constructor parameters violates single responsibility, creates merge conflicts, and makes navigation painful. REPR keeps each operation isolated.

How do you register REPR endpoints automatically?

Define an IEndpoint interface with a MapEndpoint method, scan the assembly for implementing classes at startup, and call MapEndpoint on an instance of each. This removes the need to register every endpoint manually in Program.cs.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.