.NET  

The REPR Pattern with Fast Endpoints in Modern .Net

Mature .NET systems converge on a feature-first shape where intentions are explicit, invariants live in the domain, and the HTTP layer becomes a thin adapter. That shape can be named REPR: Request > Entity > Processor > Response. It does not replace Clean Architecture or CQRS; it is what those ideas tend to produce when scaled across teams and time. This article articulates REPR as a foundational pattern and demonstrates a precise expression using FastEndpoints in modern .NET, keeping the surface small and the business logic central.

The goal is clarity, a slice per use-case, composed of an intent (Request), a truthful domain core (Entity), a use case script (Processor), and a consumer-shaped contract (Response). Controllers, service layers, and incidental abstractions fall away because the slice itself carries the meaning. The result is a codebase that reads by verbs rather than by layers, which is the natural unit of reasoning for product and engineering alike.

Why REPR Emerges from Clean Architecture and CQRS

Clean Architecture pushes dependencies inward and centres decisions in the domain. CQRS separates reads and writes to sharpen intent. Yet both leave open the practical question: What is the smallest useful unit of a feature? REPR answers with a compact pipeline. The Request captures intent without leaking storage concerns. The Entity enforces business rules and invariants. The Processor orchestrates precisely what the use-case demands and nothing more. The Response returns a business outcome suitable for its consumer, not an accidental snapshot of internal state.

This alignment reduces cognitive load. A feature has a single entry and a single exit, and the domain is the only place allowed to change truth. Cross-cutting concerns become pipeline steps around the Processor instead of boilerplate inside it. Teams scale by owning sets of verbs rather than slices of a layered cake.

The Feature Slice: Directory Shape and Mental Model

A coherent slice contains only what it needs, co-located for discoverability. An example for approving a Submission (the same structure fits quotes, orders, pricing, or claims):

/Features/Submissions/Approve
  ApproveSubmissionRequest.cs
  ApproveSubmissionResponse.cs
  ApproveSubmissionValidator.cs
  ApproveSubmissionEndpoint.cs
  ApproveSubmissionProcessor.cs
  Submission.cs
  ISubmissionRepository.cs
  EfSubmissionRepository.cs

Each file is small and purposeful. The Endpoint adapts transport to intent. The Processor applies policy and coordinates domain work. The Entity owns invariants and protects truth. The repository is a narrow port that returns the shapes the domain needs.

The REPR Flow

The Request expresses what the caller wants. The Processor asks the Entity to perform domain operations under invariant checks. The Repository persists or projects as needed. The Response communicates the outcome in consumer terms. The HTTP or messaging edge is merely a conduit.

Request and Response: Intent and Outcome

Requests model intent; they are not transport bags or EF rows. Responses model what consumers need; they are not entities.

// Request > intent, not storage schema.
public sealed record ApproveSubmissionRequest(long SubmissionId, string ApprovedBy);
// Response > consumer-shaped business outcome.
public sealed record ApproveSubmissionResponse(
    long SubmissionId,
    DateTime ApprovedAtUtc,
    string ApprovedBy,
    string StatusMessage);

Names should read like language in the domain. Versioning the Response is an explicit contract decision, not an accident.

Entity: The Only Place Allowed to Change Truth

Entities own invariants. Processors never toggle fields directly; they call domain methods that enforce rules atomically.

public sealed class Submission(long id, DateTime createdAtUtc)
{
    public long Id { get; } = id;
    public DateTime CreatedAtUtc { get; } = createdAtUtc;
    public bool IsApproved { get; private set; }
    public DateTime? ApprovedAtUtc { get; private set; }
    public string? ApprovedBy { get; private set; }
    public Result Approve(string approver, DateTime nowUtc)
    {
        if (IsApproved)
            return Result.Fail("Submission is already approved.");
        if (string.IsNullOrWhiteSpace(approver))
            return Result.Fail("Approver is required.");
        IsApproved  = true;
        ApprovedAtUtc = nowUtc;
        ApprovedBy  = approver;
        return Result.Ok();
    }
}

A small, explicit domain method prevents entire categories of drift, such as direct flag mutation scattered across handlers.

Repository Port: Narrow and Purposeful

Commands require Entities to enforce invariants; queries typically require projections. A port keeps the Processor independent of storage concerns.

public interface ISubmissionRepository
{
    Task<Submission?> GetAsync(long id, CancellationToken ct);
    Task SaveAsync(Submission Submission, CancellationToken ct);
}

Implementation choices (EF Core, Dapper, external service) do not change the Processor surface. N+1 issues are solved inside the repository contract, not leaked into callers.

Processor: The Use-Case Script

The Processor composes the use-case: load, ask, change, persist, reply. It does not perform transport work or data access gymnastics; it orchestrates policy with the Entity.

public interface IClock { DateTime UtcNow { get; } }
public sealed class SystemClock : IClock { public DateTime UtcNow => DateTime.UtcNow; }
public sealed class ApproveSubmissionProcessor(ISubmissionRepository repo, IClock clock)
{
    public async Task<Result<ApproveSubmissionResponse>> Handle(ApproveSubmissionRequest req, CancellationToken ct)
    {
        var Submission = await repo.GetAsync(req.SubmissionId, ct);
        if (Submission is null)
            return Result.Fail<ApproveSubmissionResponse>("Submission not found.");
        var approved = Submission.Approve(req.ApprovedBy, clock.UtcNow);
        if (approved.IsFailure)
            return approved.Cast<ApproveSubmissionResponse>();
        await repo.SaveAsync(Submission, ct);
        return Result.Ok(new ApproveSubmissionResponse(
            Submission.Id,
            Submission.ApprovedAtUtc!.Value,
            Submission.ApprovedBy!,
            "Approved"));
    }
}

This class should remain short. If it grows, the Entity likely needs a new domain operation to absorb rules.

FastEndpoints: The Precise HTTP Adapter

FastEndpoints models the HTTP edge with minimal ceremony. The Endpoint accepts the Request and delegates to the Processor, returning a Response or an error mapping.

using FastEndpoints;
public sealed class ApproveSubmissionEndpoint(ApproveSubmissionProcessor processor)
    : Endpoint<ApproveSubmissionRequest, ApproveSubmissionResponse>
{
    public override void Configure()
    {
        Post("/Submission/Submissions/{SubmissionId:long}/approve");
        Version(1);
        Summary(s =>
        {
            s.Summary = "Approve a Submission";
            s.Description = "Approves a Submission if business rules allow.";
            s.ExampleRequest = new ApproveSubmissionRequest(42, "Approver01");
        });
    }
    public override async Task HandleAsync(ApproveSubmissionRequest req, CancellationToken ct)
    {
        var result = await processor.Handle(req, ct);
        if (result.IsSuccess)
            await SendOkAsync(result.Value, ct);
        else
            await SendErrorsAsync(400, result.Message);
    }
}

The Endpoint remains transport-centric. It does not read data stores, compute business rules, or create hidden dependencies.

Validation: Polite at the Edge, Strict in the Core

Transport-level validation protects the Processor from malformed input, and invariants in the Entity protect the core from impossible states.

using FastEndpoints;
using FluentValidation;
public sealed class ApproveSubmissionValidator : Validator<ApproveSubmissionRequest>
{
    public ApproveSubmissionValidator()
    {
        RuleLevelCascadeMode = CascadeMode.Stop;
        RuleFor(r => r.SubmissionId).GreaterThan(0);
        RuleFor(r => r.ApprovedBy).NotEmpty().MaximumLength(200);
    }
}

This split is essential. It prevents duplication and avoids shifting business rules into validators where they cannot be enforced consistently.

Program Setup: Small Surface, Clear Wiring

Minimal registration with DI and FastEndpoints keeps the composition explicit and discoverable.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MyDbContext>(...);
builder.Services.AddScoped<ISubmissionRepository, EfSubmissionRepository>();
builder.Services.AddScoped<ApproveSubmissionProcessor>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddFastEndpoints();
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddSwaggerDoc();
var app = builder.Build();
app.UseAuthorization();
app.UseFastEndpoints();
app.UseOpenApi();
app.UseSwaggerUi3();
app.Run();

New slices add themselves by file convention; the application is composed of features rather than layers.

Query Slices: Reads Benefit Even More

Read models become explicit, projection-first, and immune to accidental IQueryable leakage. The Request captures query intent, the Processor (or Endpoint for simple reads) projects to a consumer shape.

public sealed record GetSubmissionsRequest(int Page = 1, int PageSize = 20);
public sealed record SubmissionSummary(long Id, bool IsApproved, DateTime CreatedAtUtc);
public sealed class GetSubmissionsEndpoint(MyDbContext db)
    : Endpoint<GetSubmissionsRequest, IReadOnlyList<SubmissionSummary>>
{
    public override void Configure()
    {
        Get("/uwSubmission/Submissions");
        Version(1);
    }
    public override async Task HandleAsync(GetSubmissionsRequest req, CancellationToken ct)
    {
        if (req.Page <= 0 || req.PageSize is <= 0 or > 200)
        {
            await SendErrorsAsync(400, "Invalid paging");
            return;
        }
        var data = await db.Submissions
            .AsNoTracking()
            .OrderByDescending(h => h.CreatedAtUtc)
            .Skip((req.Page - 1) * req.PageSize)
            .Take(req.PageSize)
            .Select(h => new SubmissionSummary(h.Id, h.IsApproved, h.CreatedAtUtc))
            .ToListAsync(ct);
        await SendOkAsync(data, ct);
    }
}

Read and write slices share the same mental model and directory shape, simplifying navigation and ownership.

Cross-Cutting Concerns: Pipeline, Not Boilerplate

In REPR, cross-cutting concerns belong around the Processor as pipeline steps or behaviours, not inside it. FastEndpoints supports pre/post processors at the HTTP edge; a mediator or composition pattern can provide behaviours around the Processor for logging, correlation, authorisation, idempotency, and resilience.

A simple correlation pre-processor:

public sealed class CorrelationPreProcessor<TReq, TRes>(
    ILogger<CorrelationPreProcessor<TReq,TRes>> log)
    : IPreProcessor<Endpoint<TReq,TRes>>
    where TReq : notnull
{
    public Task PreProcessAsync(Endpoint<TReq,TRes> ep, CancellationToken ct)
    {
        var cid = ep.HttpContext.TraceIdentifier;
        ep.HttpContext.Items["cid"] = cid;
        log.LogInformation("Handling {Request} cid={Cid}", typeof(TReq).Name, cid);
        return Task.CompletedTask;
    }
}

Applied globally or per endpoint, this keeps transport concerns at the edge and business concerns in the core.

Events and Outbox: Facts Out, Reliably

Many command slices emit domain events in addition to returning a Response. Pair REPR with an outbox so that event recording is transactional with state changes. The Processor writes the Entity and the outbox record within the same unit of work. A background dispatcher publishes from the outbox. The Response may carry a correlation ID for traceability without tying the API to event schemas.

This pattern aligns neatly with REPR’s flow: intent in, invariant-checked state change, facts recorded, outcome returned.

Testing: Small, Fast, and Honest

REPR improves testability by shrinking surface areas. Entities are tested directly via domain methods. Processors are tested with fake repositories and clocks, asserting on Result<T> and domain state. Endpoints can be verified with FastEndpoints’ in-memory host where HTTP-level assurance is required, but business logic never depends on a web server to be testable.

A Processor test is concise and meaningful:

[Fact]
public async Task Approve_Sets_State_And_Returns_Response()
{
    var now = new DateTime(2025, 10, 24, 12, 0, 0, DateTimeKind.Utc);
    var clock = Substitute.For<IClock>(); clock.UtcNow.Returns(now);
    var Submission = new Submission(42, now.AddDays(-1));
    var repo = Substitute.For<ISubmissionRepository>();
    repo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(Submission);
    var processor = new ApproveSubmissionProcessor(repo, clock);
    var result = await processor.Handle(new ApproveSubmissionRequest(42, "PK"), CancellationToken.None);
    result.IsSuccess.ShouldBeTrue();
    Submission.IsApproved.ShouldBeTrue();
    Submission.ApprovedAtUtc.ShouldBe(now);
    Submission.ApprovedBy.ShouldBe("PK");
    await repo.Received(1).SaveAsync(Submission, Arg.Any<CancellationToken>());
}

The test demonstrates intent, rule enforcement, and persistence orchestration without scaffolding overhead.

Anti-Patterns to Avoid

A few pitfalls undermine the benefits:

  • Requests that mirror database rows rather than intent encourage accidental coupling and bloat.

  • Processors that toggle fields directly instead of calling domain methods bypass invariants.

  • Repositories that return IQueryable leak persistence concerns and invite N+1 issues outside the boundary.

  • Responses that expose Entities create fragile contracts and accidental internal coupling.

  • Generic “BaseRepository<T>” or “Service” classes that centralise convenience instead of domain language lead to anaemic designs.

REPR’s strength is clarity; resist abstractions that obscure intent.

Impact and Ownership

Teams reason by verbs and outcomes. PRs review faster when a slice reads like a story, Request, Processor, Response, with invariants visible in an Entity. Observability improves because logs and metrics align with Request types and semantic operations instead of raw URLs. Ownership maps to features rather than controllers or horizontal layers, enabling autonomous delivery without accidental contention.

REPR is not a framework and does not require a mediator, controller, or a particular storage engine. It is the minimal shape a serious codebase adopts when Clean Architecture and CQRS are applied with discipline and scaled across a product’s lifetime. Expressed with FastEndpoints, the HTTP edge becomes quiet and intention-based, and the domain regains custody of truth.

The practical move is simple: for the next non-trivial feature, create a slice named for the verb. Write the Request so the intent is explicit. Put rules in an Entity method. Keep the Processor short and orchestral. Return a Response that a consumer would actually want. Repeat until the architecture fades into the furniture and only the product remains visible.