REPR: The Quiet Evolution of Clean Architecture and CQRS in Modern .NET

If you’ve been building serious .NET systems for a few years, you’ve probably ended up with vertical feature slices, slim controllers, and a handler that orchestrates domain work. You may not have given it a name, but there’s a high chance you ended up with REPR - Request > Entity > Processor > Response. It’s not a framework or a library, it’s a way of composing features so that your application expresses intentions rather than mechanics. REPR isn’t a rejection of Clean Architecture or CQRS. It’s the next, quieter step those approaches converge toward when you scale.
Across large engineering teams, from the bank-backed scale ups to FAANG, you’ll find structures that look suspiciously like REPR even if nobody uses the term. Netflix’s emphasis on single responsibility components, GitHub’s product slice autonomy, and Monzo’s event first thinking in platform services all repeat the same move, make each feature a self contained pipeline with clear boundaries. In the .NET world, REPR is how that idea lands in code. Below we can see what REPR looks like, why it’s a natural evolution of Clean Architecture + CQRS, and how to deploy it incrementally without to much hassle.
Why REPR emerges in grown up codebases
Classic MVC puts controllers at the centre, and everything else hangs off them. Thats fine until its not enough. As features multiply, controllers become routers with opinions, and the real decisions leak into ad hoc services. Clean Architecture pushes the decisions into the domain and forces dependencies inward, which is a huge improvement, but teams still argued about where use cases should live and how to keep handlers from collapsing into thin orchestration.
CQRS helped by splitting read and write flows, but it didn’t dictate how to structure a single feature unit. You still needed a pattern to express the shape of a use case. REPR fills the gap by making each feature a small, discoverable pipeline:
A Request that is the user’s intent (API input, message payload, scheduled job trigger).
An Entity that is the domain root(s) we’re going to change or read consistently.
A Processor that applies policy and coordinates domain work (your use-case brain).
A Response that returns business level results (API DTO, event, or status).
REPR shifts the centre of gravity away from controllers and towards the feature. You no longer ask which controller? but which intent? The controller becomes a tiny adapter whose only job is to turn an HTTP exchange into a Request and then hand it to the Processor. The Processor loads and manipulates Entities under invariant rules and emits a Response. That’s it. It is CQRS by posture, Clean Architecture by dependency rule, and domain-first by attitude.
The shape of a vertical slice
A typical feature directory contains only what the feature needs, nothing global, nothing clever. Here’s a sketch for “Approve Submission”, but the pattern holds for pricing, quotes, orders, or any other business verb.
/Features/Submissions/Approve
ApproveSubmissionRequest.cs
ApproveSubmissionResponse.cs
ApproveSubmissionProcessor.cs
Submission.cs // Aggregate root for this slice
SubmissionRepository.cs // Port to persistence
ApproveSubmissionEndpoint.cs // Thin HTTP adapter
Each file is small. The endpoint delegates, the processor thinks, the entity enforces invariants. The repository is a port that returns or persists the entity in a shape that suits the domain, not the database.
Modern .NET, minimal noise
You don’t need a library to do REPR. Modern .NET gives you everything out of the box. The samples below use primary constructors, results for error flow, and a validator that sits in front of the Processor. Use any flavours you like, the pattern is independent of frameworks.
The Request & Response
// Request → intent, not transport.
// Use PascalCase for commands in CQRS/REPR; treat it like a business message.
public sealed record ApproveSubmissionRequest(long SubmissionId, string ApprovedBy);
// Response → business outcome, not entity leak.
public sealed record ApproveSubmissionResponse(
long SubmissionId,
DateTime ApprovedAtUtc,
string ApprovedBy,
string StatusMessage);
Keep them tiny. Requests model intent, not your database. Responses are what happened, not your EF entities. If you need pagination, projections, or hyperlinks, add them deliberately.
The Entity
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();
}
}
The entity owns the business rules. The Processor doesn’t toggle flags directly; it calls Approve and handles the result. That one move protects you from a thousand “just set the field” PRs.
The Repository port
public interface ISubmissionRepository
{
Task<Submission?> GetAsync(long id, CancellationToken stopToken);
Task SaveAsync(Submission submission, CancellationToken stopToken);
}
Implementation can be EF Core, Dapper, or an API call, the feature doesn’t care. The Processor depends on the interface, not the storage.
The Processor
public sealed class ApproveSubmissionProcessor(ISubmissionRepository repo, IClock clock)
{
public async Task<Result<ApproveSubmissionResponse>> Handle(
ApproveSubmissionRequest request,
CancellationToken stopToken)
{
var Submission = await repo.GetAsync(request.SubmissionId, stopToken);
if (Submission is null)
return Result.Fail<ApproveSubmissionResponse>("Submission not found.");
var approved = submission.Approve(request.ApprovedBy, clock.UtcNow);
if (approved.IsFailure)
return approved.Cast<ApproveSubmissionResponse>();
await repo.SaveAsync(submission, stopToken);
return Result.Ok(new ApproveSubmissionResponse(
submission.Id,
submission.ApprovedAtUtc!.Value,
submission.ApprovedBy!,
"Approved"));
}
}
The Processor is the use case script. Short, readable, and nothing infrastructural. Validation sits just in front of it, invariants sit just beneath it.
The Endpoint (thin adapter)
public sealed class ApproveSubmissionEndpoint : IEndpoint
{
public void MapEndpoint(IEndpointRouteBuilder app)
{
app.MapPost("/submission/submissions/{id:long}/approve",
async (long id, [FromBody] ApproveRequestBody body,
ApproveSubmissionProcessor processor, CancellationToken stopToken) =>
{
var request = new ApproveSubmissionRequest(id, body.ApprovedBy);
var result = await processor.Handle(request, stopToken);
return result.Match(
ok => Results.Ok(ok),
err => Results.Problem(title: "Approval failed", detail: err.Message));
})
.WithName("ApproveSubmission");
}
public sealed record ApproveRequestBody(string ApprovedBy);
}
Controllers aren’t wrong, they’re just no longer the centrepiece. The endpoint does translation and nothing else.
Validation without noise
Place transport level checks (shape, ranges, formats, UTC dates) in a validator that runs before the Processor. Keep invariant enforcement inside the Entity. That separation makes your domain honest and your API polite.
using FluentValidation;
public sealed class ApproveSubmissionRequestValidator : AbstractValidator<ApproveSubmissionRequest>
{
public ApproveSubmissionRequestValidator()
{
RuleLevelCascadeMode = CascadeMode.Stop;
RuleFor(r => r.SubmissionId).GreaterThan(0);
RuleFor(r => r.ApprovedBy).NotEmpty().MaximumLength(200);
}
}
Wire it as middleware or as a pipeline behaviour, either way, the Processor sees only valid intent.
Pipelines, behaviours & middleware
REPR shines when you add pipeline behaviours around your Processors, logging, correlation IDs, retry policies, idempotency checks, and authorisation guards. The behaviours wrap every Processor handle, so you get consistent cross cutting concerns without contaminating business logic.
public interface IRequestHandler<TRequest, TResponse>
{
Task<Result<TResponse>> Handle(TRequest request, CancellationToken stopToken);
}
public interface IPipelineBehavior<TRequest, TResponse>
{
Task<Result<TResponse>> Handle(
TRequest request,
CancellationToken stopToken,
Func<TRequest, CancellationToken, Task<Result<TResponse>>> next);
}
public sealed class LoggingBehavior<TRequest, TResponse>(ILogger<LoggingBehavior<TRequest,TResponse>> log)
: IPipelineBehavior<TRequest, TResponse>
{
public async Task<Result<TResponse>> Handle(
TRequest request, CancellationToken stopToken,
Func<TRequest, CancellationToken, Task<Result<TResponse>>> next)
{
log.LogInformation("Handling {RequestType}", typeof(TRequest).Name);
var result = await next(request, stopToken);
log.LogInformation("Handled {RequestType} -> {Outcome}",
typeof(TRequest).Name, result.IsSuccess ? "Success" : "Failure");
return result;
}
}
You can stack resilience behaviours (Polly/Microsoft.Extensions.Resilience), metrics emission, and audit trails. REPR doesn’t fight these concerns, it invites them to the right layer.
Reads belong to REPR
Developers often think REPR only applies to commands. In practice, read models benefit even more. A Request expresses the query intent, the Processor composes the projection, and the Response is the shaped data. Keep it crisp, use .AsNoTracking() and projection early to avoid N+1 traps.
(I recently wrote about N+1 here)
public sealed record GetSubmissionsRequest(int Page, int PageSize);
public sealed record SubmissionSummary(long Id, bool IsApproved, DateTime CreatedAtUtc);
public sealed class GetSubmissionsProcessor(MyDbContext db)
{
public async Task<Result<IReadOnlyList<SubmissionSummary>>> Handle(GetSubmissionsRequest req, CancellationToken stopToken)
{
if (req.Page <= 0 || req.PageSize is <=0 or > 200)
return Result.Fail<IReadOnlyList<SubmissionSummary>>("Invalid paging");
var query = 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));
var data = await query.ToListAsync(stopToken);
return Result.Ok<IReadOnlyList<SubmissionSummary>>(data);
}
}
This keeps the read slice standalone. If you later move submissions to a dedicated read store or cache, only this Processor changes.
How REPR evolves Clean Architecture + CQRS
Clean Architecture gave us inversion of control and use case intermediation. CQRS sharpened intent by separating reads and writes. REPR tightens things and localises decisions. Instead of huge application services or thin handlers, each feature is a small pipeline with three rules:
The Request is the truth of intent.
It’s what the user or system wants to do or know. It’s not an EF model or a controller parameter bag.The Entity owns invariants.
The Processor never setsIsApproved = true, it callsApprove(). That is the thin line between a coherent core and a distributed bag of flags.The Processor composes the use case and emits a Response.
It loads, asks, commands, persists, and returns a business outcome. It doesn’t know about HTTP, and it doesn’t leak storage concerns upward.
This alignment makes vertical slices genuinely independent. You can ship features without stepping on each other. GitHub’s approach to “ship small, ship often” is totally compatible with REPR because features are shippable units. Monzo’s domain led teams repeat the same structure internally, message in > policy > state change > event out.
Migration without drama
You don’t have to burn your controllers. Start with one endpoint that’s currently messy. Create a feature folder, write a Request/Response, move the domain object or create a thin entity wrapper, and add a Processor. In the controller action, instantiate the Request and invoke the Processor. Merge. Done.
Repeat for the next mess. Keep your existing DI setup; register Processors as scoped services. Introduce behaviours when you see repetition. Over a sprint or two, the hotspot areas of your codebase will look similar, small, navigable slices where the entry file reads like a story.
Testing
REPR’s most practical gift is what it does to tests. You can instantiate a Processor in isolation, mock a repository or use an in memory store, and assert on a Response. You can test the Entity, calling Approve() and asserting state and failure messages. You can test a validator without spinning up ASP.NET. Integration tests can target the endpoint adapter if you wish, but you’re no longer forced to go through HTTP to test business logic.
A typical unit test:
[Fact]
public async Task Approve_Submission_Sets_State_And_Emits_Response()
{
var now = new DateTime(2025, 10, 23, 12, 0, 0, DateTimeKind.Utc);
var clock = new FakeClock(now);
var submission = new Submission(id: 42, createdAtUtc: now.AddDays(-1));
var repo = new FakeRepo().With(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");
}
No hosted server. No controllers. Pure business code.
Handling cross cutting concerns
Large organisations tend to accumulate swathes of cross cutting policy, correlation IDs, authorisation, audit trails, retries, idempotency, data masking, PII redaction, and more. In controller centric designs these drift into action filters and base controllers. In REPR, you add a behaviour to the Processor pipeline and you’re done.
Authorisation: run a guard behaviour that evaluates user roles/claims against the
Requestshape.Idempotency: deduplicate based on a key present in the
Request(e.g., commandId) and short circuit thenext.Resilience: wrap repository calls in a policy, wire via dependency or inside a behaviour if consistent across all Processors.
Because Requests are explicit, guards can be deterministic and auditable. You can even use a Roslyn analyser to ensure every Processor is wrapped by certain behaviours in DI.
Performance and the N+1 trap
REPR doesn’t fix N+1 by itself, but it makes it obvious who owns the fix. Reads should project early and be .AsNoTracking(). Writes should load the minimum shape necessary to enforce invariants. If an invariant demands related data, eager load that relation explicitly in the repository. Because the Processor depends on a repository interface, you can swap to compiled queries, Dapper, or a read cache without touching the feature’s public surface.
The pattern nudges better boundaries, your Repository returns Entities or purpose-built DTOs, not IQueryable<T> that leaks into the Processor. As a result, accidental N+1 caused by deferred LINQ outside the persistence boundary simply doesn’t happen.
Events, outboxes, and REPR
In evented systems, the Response is often two sided, the API returns a DTO, and the Processor also emits a domain event. Pair REPR with an outbox so that event publishing is transactional with your state change. The Processor writes the entity and the event together, a background dispatcher drains the outbox. In request response scenarios, you can surface the event ID in the Response for traceability without coupling your API to the event schema.
This is where Netflix style “tell, don’t ask” and Monzo’s ledger thinking reinforce the same habit, intent in, state change under invariants, facts out. REPR is simply how that looks in a .NET feature slice.
Anti patterns to resist
The temptations are predictable. Don’t let the Request become a “god DTO” that mirrors your entire database row, keep it as intent. Don’t put database toggles in the Processor; push them into Entity methods. Don’t return Entities to controllers, turn decisions into a Response designed for the consumer. Don’t turn repositories into generic “BaseRepository<T>” that leak EF idioms into your use cases, design ports that speak the language of the Entity.
If you inherit a codebase full of “service services”, carve a thin REPR slice around one use case and let that become the example. Engineers copy what feels better to work with.
Foldering, naming, and the day two ergonomics
Engineers live inside their editor more than any architecture diagram. The test of a good pattern is whether a newcomer can find the right file in under five seconds. With REPR, you teach them one rule, open the feature folder named for the verb, and everything you need is there. The next on call incident becomes “find the slice, run the tests, add a guard”, not “follow a call graph across six projects”.
Name Processors for the action (ApproveSubmission, CalculatePremium, RenewPolicy). Name Requests for intent (ApproveSubmissionSubmissionRequest). Keep Response names parallel. Apply the same discipline to your events (“SubmissionApproved”) and your logging contexts. Boring naming is a feature, not a bug.
A bigger picture
There’s a reason big teams drift toward this shape. It’s easier to review. It’s easier to secure. It produces less surprise in production. When you open a PR at GitHub or Netflix scale, reviewers look for intention first, what is this feature trying to do? REPR encodes that intention into the files themselves. Observability is simpler because your logs can key off RequestType. Incident responders can set up dashboards by Request/Response semantic names rather than raw URLs. Product managers can search the repo for a verb and land on the relevant pipeline.
REPR also supports steady-state autonomy. Teams can own sets of features without having to “own the controllers” or “own the shared service layer”. Ownership maps to verbs, deployment maps to slices. That is how you scale engineering organisations without introducing more “platform” than necessary.
Bringing it all together in Program.cs
There’s nothing special to wire up. Register your repositories, validators, and behaviours, expose endpoints that adapt HTTP to Requests. If you prefer and its not overkill, add a simple “Processor mediator” to centralise behaviour chains, or inject Processors directly. Here’s a thin example:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MyDbContext>(...);
builder.Services.AddScoped<ISubmissionRepository, EfSubmissionRepository>();
builder.Services.AddScoped<ApproveSubmissionProcessor>();
builder.Services.AddScoped<GetSubmissionsProcessor>();
builder.Services.AddValidatorsFromAssemblyContaining<ApproveSubmiddion RequestValidator>();
// Optional: register pipeline mediator and behaviours if you want dynamic chaining
// builder.Services.AddProcessorMediator()
// .AddBehavior(typeof(LoggingBehavior<,>))
// .AddBehavior(typeof(AuthorizationBehavior<,>));
var app = builder.Build();
new ApproveSubmissionEndpoint().MapEndpoint(app);
// ... map other feature endpoints
app.Run();
You’re composing an application from features, not from controllers or “modules” whose only purpose is to group files. It feels small because it is.
Name the thing and move on
Patterns become powerful when you can name them and carry on with the work. REPR is a small name for something you already sense, intent centric slices with honest entities and processors that think. It’s where Clean Architecture and CQRS were pointing all along. It scales in companies with many teams because it maps to how humans reason about software, by verb, not by layer.
The next time you sketch a use case, title the page “Request > Entity > Processor > Response”. Write the Request first so you’re forced to state the intent. Give the Entity one method that enforces the rule. Keep the Processor short. Return a Response that a product manager could read aloud. That’s the pattern. That’s how teams at Monzo, GitHub, and Netflix scale, keep features shippable without drowning in architecture rules.
This REPR example from Milan Jovanović makes use of Fast Endpoints which I previously wrote about here.






