Skip to content

REPR

The REPR design pattern formalizes the separation between three distinct responsibilities in an API:

  1. Request — a dedicated type modeling incoming data
  2. Endpoint — a single handler processing the request
  3. Response — a dedicated type modeling the output, decoupled from the domain model

This pattern opposes monolithic MVC controllers that group dozens of unrelated actions, each with different dependencies.

flowchart LR
    subgraph REPR["REPR -- per operation"]
        direction LR
        Req["Request<br/>sealed record"]
        EP["Endpoint<br/>private static method"]
        Res["Response<br/>sealed record"]
        Req --> EP --> Res
    end

    subgraph MVC["MVC -- avoid"]
        direction LR
        C["Controller<br/>N actions<br/>N x M dependencies"]
    end

    style REPR fill:#f0f9f0,stroke:#2d8a4e
    style MVC fill:#fef0f0,stroke:#c44e4e
sequenceDiagram
    participant C as HTTP Client
    participant MW as Middleware<br/>(Validation, Auth, Tenant)
    participant EP as Endpoint Handler
    participant SVC as Service / Store
    participant DB as Database

    C->>MW: POST /api/tasks (TaskCreateRequest)
    MW->>MW: FluentValidation<br/>JWT / RBAC
    MW->>EP: Validated request
    EP->>SVC: CreateAsync(request, ct)
    SVC->>DB: INSERT
    DB-->>SVC: Entity
    SVC-->>EP: TaskResponse
    EP-->>C: 201 Created (TaskResponse)

Granit adopts REPR principles while adapting them to native .NET Minimal APIs, without external dependencies (FastEndpoints, MediatR, Ardalis).

Strict REPR prescribes one class per endpoint. Granit groups handlers in a static extensions class on RouteGroupBuilder, organized by feature (read, admin, sync, etc.). This pragmatic choice reduces file count while preserving the Request / Endpoint / Response separation.

CharacteristicStrict REPR (FastEndpoints / Ardalis)Granit
Dedicated Request/Response per operationYesYes
No EF entity returnYesYes
One file/class per endpointYesNo — grouped by feature
External dependencyYesNo — native Minimal API
TestabilityVia base class / harnessPure static methods
src/Granit.{Module}.Endpoints/
-- Dtos/
-- {Module}{Action}Request.cs <- Request (input body / query)
-- {Module}{Action}Response.cs <- Response (output)
-- Endpoints/
-- {Module}ReadEndpoints.cs <- Endpoint (read handlers)
-- {Module}AdminEndpoints.cs <- Endpoint (admin handlers)
-- Extensions/
-- {Module}EndpointRouteBuilderExtensions.cs <- Public entry point
-- Granit{Module}EndpointsModule.cs

A sealed record per write or search operation.

Binding typeConvention
Body (POST/PUT){Module}{Action}Request — positional record or with required
Query string{Module}ListRequest with [AsParameters]
RouteDirect primitive parameters (Guid id, string code)

Rules:

  • Request suffix mandatory (never Dto)
  • Business prefix mandatory: WorkflowTransitionRequest, not TransitionRequest (OpenAPI flattens namespaces, causing schema collisions)
  • Cross-cutting types exempt from prefix: PagedResult(T), ProblemDetails

A private static method in an internal static extensions class.

RuleDetail
Visibilityprivate static (handler), internal static (mapping method)
Asyncasync Task(T), ConfigureAwait(false) in library code
Last parameterCancellationToken cancellationToken
ReturnTypedResults.* (never Results.* or IResult)
Metadata.WithName(), .WithSummary(), .WithTags() required
Inline lambdasForbidden — no type inference, unreadable

A sealed record distinct from the EF Core entity.

RuleDetail
SuffixResponse (never Dto)
Anonymous typesForbidden — unnamed OpenAPI schema
Direct EF entitiesForbidden — coupling DB schema to API contract
ErrorsTypedResults.Problem() (RFC 7807), never BadRequest(string)
Multi-status returnResults(Ok(T), NotFound), Results(Created(T), ProblemHttpResult)
ModuleFileSpecificity
Identitysrc/Granit.Identity.Endpoints/Endpoints/IdentityUserCacheReadEndpoints.csFull CRUD, DTOs separated in Dtos/
Notificationssrc/Granit.Notifications.Endpoints/Endpoints/MobilePushTokenEndpoints.csDTOs inline in the endpoint file
ReferenceDatasrc/Granit.ReferenceData.Endpoints/Endpoints/ReferenceDataAdminEndpoints.csGeneric (TEntity) endpoints
DataExchangesrc/Granit.DataExchange.Endpoints/Endpoints/Import/ImportUploadEndpoints.csIFormFile upload, complex validation
Authorizationsrc/Granit.Authorization.Endpoints/Endpoints/MyPermissionsEndpoints.csResponse-only, no Request body
ProblemREPR solution
Monolithic MVC controllers with N injected dependenciesEach handler receives only its own dependencies via parameter DI
EF entity returned = coupling DB schema to API contractDedicated Response record, stable OpenAPI contract
Opaque or unnamed OpenAPI schemaTypedResults + typed records = deterministic schema
Third-party framework dependency (FastEndpoints, MediatR)Native Minimal API, zero dependency for consumers
File explosion (one per endpoint x N modules)Grouping by feature in RouteGroupBuilder extensions
// --- Dtos/TaskCreateRequest.cs ---
/// <summary>Request to create a new task.</summary>
public sealed record TaskCreateRequest(string Title, string? Description = null);
// --- Dtos/TaskResponse.cs ---
/// <summary>Represents a task returned by the API.</summary>
public sealed record TaskResponse(Guid Id, string Title, string? Description);
// --- Endpoints/TaskEndpoints.cs ---
internal static class TaskEndpoints
{
internal static void MapTaskRoutes(this RouteGroupBuilder group)
{
group.MapGet("/{id:guid}", GetByIdAsync)
.WithName("GetTask")
.WithSummary("Returns a task by its unique identifier.");
group.MapPost("/", CreateAsync)
.WithName("CreateTask")
.WithSummary("Creates a new task.");
}
private static async Task<Results<Ok<TaskResponse>, NotFound>> GetByIdAsync(
Guid id,
ITaskReader reader,
CancellationToken cancellationToken)
{
TaskResponse? task = await reader.FindAsync(id, cancellationToken)
.ConfigureAwait(false);
return task is not null
? TypedResults.Ok(task)
: TypedResults.NotFound();
}
private static async Task<Created<TaskResponse>> CreateAsync(
TaskCreateRequest request,
ITaskWriter writer,
CancellationToken cancellationToken)
{
TaskResponse created = await writer.CreateAsync(request, cancellationToken)
.ConfigureAwait(false);
return TypedResults.Created($"/{created.Id}", created);
}
}
// --- Extensions/TaskEndpointRouteBuilderExtensions.cs ---
public static class TaskEndpointRouteBuilderExtensions
{
public static RouteGroupBuilder MapTaskEndpoints(
this IEndpointRouteBuilder endpoints)
{
RouteGroupBuilder group = endpoints
.MapGroup("/api/tasks")
.WithTags("Tasks")
.RequireAuthorization();
group.MapTaskRoutes();
return group;
}
}