← Back to list

VSA + CQRS without turning into a framework: the shared language your team has been wanting

Part four of the series “From Legacy Architecture to a Team That Ships.” In the previous articles, we moved from Clean Architecture to…

Jean Puga · 2026-05-18 02:53 · 0 claps · 14.3 min read
#software-architecture #ddd #clean-architecture #dotnet #artificial-intelligence
Open on Medium ↗
Wiki topics: AI · AI · General 🏛️ · Architecture

VSA + CQRS without turning into a framework: the shared language your team has been wanting

Part four of the series “From Legacy Architecture to a Team That Ships.” In the previous articles, we moved from Clean Architecture to Vertical Slice, aligned the backlog with the code, and showed how Team Topologies produces DDD without the team realizing it. Now we’re going down to the file level, where silent decisions become convention.

A note before we start. This article had a different working title: “Goodbye paid MediatR: native CQRS, fast-fail, and behaviors with no external dependency.” It was a direct promise, drop a library whose license changed, build the pipeline yourself, end of story.

I changed my mind while drafting it. It’s worth explaining why, because the reason is part of the thesis.

Swapping a dependency is a technical decision that takes a few hours: you read the code, map the interface, write the adapter, run the tests. The hard part is doing it before the team has clarity on what each use case should look like in the project. Without that convention, “remove MediatR” becomes “every feature rebuilds the pipeline the way the dev learned at their previous company”, and you’ve traded a licensing problem for a divergence problem, which is far worse.

So we’ll start from the foundation. Today is about the convention that precedes the stack decision, scaling out the idea of reshaping the project before touching the dependencies. Once that layer is settled, saying goodbye to paid MediatR becomes a PR that takes a few hours, not a silent rewrite that no one will have the nerve to approve. Promise deferred, not canceled.

There’s a pattern that plays out in nearly every team that adopts an architectural convention, VSA, Clean, Hexagonal, take your pick. The first months are beautiful. Then, slowly, things start to drift.

You finish migrating three features. The folders are organized by Features, each with its Command, its Handler, its route. The code breathes. The squad can show what it ships.

Then the fourth dev comes in on the next feature. And they do everything again, but in a way that’s almost the same.

Their Handler returns Task<Result> instead of ValueTask<Result>. Their validation lives inside the Handler, not in a separate Validator. Their authorization policy ended up in the global Program.cs, not in the feature’s module. Their try/catch swallows the exception and returns Results.Problem(ex.Message), masking the ValidationException that the global pipeline was already turning into Problem Details RFC 7807.

None of this is wrong from a technical standpoint. All of this is wrong from a team standpoint.

And if you got here through DDD, through the series, or on your own, what we’re talking about is Ubiquitous Language, just between devs. The PO negotiates terms with the domain in a meeting room; we negotiate among folders, namespaces, and method signatures. Same motion, different layer, and the conversation from here on is ours.

A short history to frame the conversation

It’s worth spending a couple of paragraphs on how the concept evolved, because it explains why so many people miss the mark when adopting VSA.

Jimmy Bogard, the same Jimmy Bogard behind MediatR, popularized Vertical Slice Architecture in 2018, in the talk “SOLID Architecture in Slices, not Layers.” His message was simple: most of the problems Clean Architecture tries to solve with layers are better solved with slices. Each feature carries what it needs. High cohesion inside the feature, low coupling between features. There’s no canonical repository, VSA lived across blogs, talks, and scattered examples for years.

Nadir Badnjevic produced the educational .NET adaptation in VerticalSliceArchitecture (started in 2022, still maintained as of 2026). It’s the repository you’d send to someone just getting started, clean, with MediatR, easy to read.

Luke Parker (handle Hona) / SSW raised the bar for enterprise projects. There are two generations worth knowing about: version 1 at SSWConsulting/SSW.VerticalSliceArchitecture, from 2023, where I personally drew a lot of inspiration, and where ideas we still use today were born, and version 2 at Hona/VerticalSliceArchitecture, built around FastEndpoints for endpoints, Vogen for source-generated value objects, ErrorOr for errors as values, Ardalis.Specification for data access, and .NET Aspire for orchestration. Luke’s .NET Conf 2023 talk, Vertical Slice Architecture: How Does it Compare to Clean Architecture,” is still the video I recommend before any adoption meeting.

The arc is clear: concept → educational example → enterprise-grade foundation. Each author solved a different problem. And none of the three give you what you actually need when your team grows: a convention so clear that a new dev, a senior dev, and a language model all land in the same place.

Looking back, it’s clear that my “contribution” here is the stitching. The rest stands on shoulders that aren’t mine, Bogard, Badnjevic, Parker, Othamar. Newton was on to something with that image.

That’s the problem I wanted to talk about.

Why context switching is expensive, for everyone

Here’s a calculation few people do.

When an AI handles your request, Copilot, Cursor, Claude Code, any of them, it charges tokens proportional to the context it has to load. If “upload a file” in your project requires opening Controller, AppService, DomainService, Repository, Entity, DTO, Mapper, Validator, and three interfaces just to understand how you people do this around here, you just paid the equivalent of an entire feature in context before the AI wrote a single line.

Now think about the human dev.

They walk into the project. They’re going to work on “cancel approved order.” How many files do they have to look at before they know how you people do this around here? How many implicit conventions will they trip over before their first PR? How many times will they request review with “I thought AutoMapper would be better here” and spend 40 minutes of a meeting explaining why?

Context switching isn’t just an LLM token-spend problem. It’s the same loss, in a different currency, when a programmer leaves execution mode and enters “creative-philosopher-architect” mode. And the only way to lower that cost on both sides is to have a structure so predictable that humans and machines know where to look before they look.

VSA gives you half that structure. The other half is convention.

The convention: five files, one partial class, one namespace

Our team’s rule is simple: each use case is a sealed partial class, split across five files, inside UseCases/.

Command for mutations. Query for reads with no side effects. Each file is a face of the same class. You open the folder, read all five, and that’s it, you don’t need to look anywhere else in the project.

This isn’t building a framework. Building a framework means creating abstraction that decides for you. This is the opposite: the convention removes the discussion, but every file is direct code, with no runtime reflection magic.

A word on the example I’m going to use. Almost every .NET architecture article picks a CRUD to demonstrate, a User, a Product, an Order that inserts and updates rows. I avoided that on purpose. CRUD with an anemic domain is the most favorable scenario possible for any architecture, any template looks elegant when the operation is a dbContext.Add() followed by SaveChanges().

Real software is rarely like that. When the use case involves external I/O, validation that depends on business rules, a SignalR event to the front end, an auxiliary database write that mustn’t take down the main operation, that’s where each team invents a different way to organize. It slides into improvisation. It slides into “but our flow is special.” And every feature turns into a new negotiation.

That’s why I picked an S3 multipart upload as the running example. It has external calls, multi-step orchestration, an auxiliary database write that needs to be fail-silently, and real-time notification. It’s a reasonably uncomfortable scenario, and the article’s thesis is precisely that it fits in the same five files a CRUD would. If the convention survives here, it survives almost anywhere.

Let’s walk through each file.

Command, the input contract

record for immutability. sealed because nobody’s going to inherit. IRequest<Result> so the Mediator can find the Handler at build time, via source generator. There’s no secret here, and that’s precisely the point.

Result, the output contract

Same namespace, same partial. Whoever opens the file knows that Result belongs to that feature. In another feature, Result means something else, and that’s fine, because the name only exists inside the parent class’s context.

Validator, where input rules live

Here’s the first thing I’d change if I could go back to my early VSA projects.

Validator is partial on the same StartMultipartUpload. Whoever reads the Command sees the Validator right next to it, in the same folder, with the same name. There’s no “where’s the validation for this route?”, it’s literally right there.

The team’s rule: format validation lives in the Validator. Business invariant validation (which requires I/O) lives in the Handler. String length, numeric range, email format, required fields, all in the Validator. “Does this user have permission to touch this specific order?”, Handler.

And the Validator isn’t decoration. It runs before the Handler via Mediator’s ValidationBehaviour, which we register at the Application layer:

Failures become FluentValidation.ValidationException, which bubbles up to the global KnownExceptionsHandler and turns into HTTP 400 with Problem Details RFC 7807, including an errors extension listing each failure by field and code. The endpoint doesn’t even need to know this exists.

Handler, where business happens

Three rules worth keeping in mind:

  • ValueTask<Result> by default, with care. Mediator expects this signature, and when the handler is synchronous you get zero allocation (ValueTask.FromResult(…) on the fast path). But the official Microsoft documentation is explicit about the pitfalls: ValueTask doesn’t tolerate awaiting the same instance multiple times, shouldn’t be passed to Task.WhenAll/WhenAny, and in repeated synchronous flows it can disable instance reuse and give you surprising behavior. Use it as the Handler’s return contract, where the call is single and linear. If you need to compose with several parallel tasks, materialize with .AsTask() first. The practical rule: ValueTask in signatures, Task in composition.
  • Orchestration, not operation. The Handler calls IAmazonS3, IImportFileRepository, IHubContext. The operation itself lives in Services. The Handler is the conductor.
  • Fail-silently only where we agreed. Registering import metadata in the database is auxiliary, if it fails, log and move on. Uploading the file to S3 is the main operation, if it fails, the exception bubbles up and becomes Problem Details.

Endpoint, where the API ends

And here’s the part that hurt the most to change.

Notice what’s not there?

No try/catch wrapping sender.Send. No Results.Problem(ex.Message) in a catch. No guard clause for command.FileName == null. No [FromServices] ILogger.

This was deliberate, and it took three PR reviews before the team got used to it.

The endpoint only injects ISender. Everything business-related flows through Send. And when something goes wrong:

  • Format validation? ValidationException → global KnownExceptionsHandler → Problem Details 400.
  • Resource doesn’t exist? NotFoundException → 404.
  • Business rule violated? IBusinessException → appropriate status.
  • Unexpected? UnhandledExceptionBehaviour logs it, and the global ExceptionHandler returns 500 already formatted.

When you wrap Send in a generic try/catch that returns Results.Problem(ex.Message), you mask the ValidationException. The client gets a 500 (or worse, a 200 with an error message in the body) instead of a 400 with structured errors. And the whole team loses the RFC 7807 contract you agreed on at the Application layer.

Guard clauses in the endpoint? Only for what doesn’t pass through the typed body, headers, route values, raw query strings that need handling before becoming a Command. If it’s a property of the Command, it goes in the Validator.

The feature as a self-contained module, authorization included

This is, in my opinion, the most underrated gain of this convention.

Each feature carries its own bootstrap. Configuration, dependencies, and authorization policies. No Program.cs ballooning into a 200-line list registering policies for each route.

The UploadsWriter policy lives as a public constant in the feature’s namespace. The endpoint references it by strong name (UploaderAuthorizationPolicies.UploadsWriter), not by magic string. If a dev renames the constant, the compiler complains at every endpoint. If the entire feature is removed, the policy goes with it, no orphans in Program.cs.

The only prerequisite is that the JWT pipeline is mounted before the modules: AddJwtServices(configuration) in Program.cs, before ConfigureModules(…). After that, each feature decides what it requires from whom.

And when a route only needs “to be authenticated”? policy.RequireAuthenticatedUser() with no RequireAssertion. Simple.

And Program.cs? Small, the way it should be

There’s a legitimate question that comes up every time the team sees this structure for the first time: “OK, each feature has its IModule and its IEndpointDefinition. But who registers all of that in Program.cs?”

The answer is: nobody registers manually. Program.cs scans the assembly for implementations of those interfaces and calls each one in the right order. Strategy Pattern applied to application composition.

The idea isn’t mine. It comes straight from the v1 of Luke Parker / SSW’s template, in 2023, the same repository I referenced above. That version loaded IModule via automatic scanning, and that’s where I picked up the pattern. When Luke pivoted to v2, he chose FastEndpoints to handle the endpoint side. We went a different way on that specific point: replicate the same scanning technique for our own interface, IEndpointDefinition, and stay on native Minimal APIs, with no intermediate library at all.

The choice has a technical justification, and it matters. For new projects, Microsoft explicitly recommends Minimal APIs over controllers. It’s in the official documentation, no hedging:

“Minimal APIs are the recommended approach for building fast HTTP APIs with ASP.NET Core.”

Microsoft Learn, APIs overview (ASP.NET Core)

When Microsoft puts its name on something, two arguments disappear from the architecture meeting: “that’s niche” and “that could be deprecated.” Minimal APIs are the official path, they get optimization with every release, source generator AOT-ready since .NET 8, and the internal Microsoft team treats this path as a priority.

I was tempted to adopt FastEndpoints mid-project, it’s Luke’s choice in v2, and an excellent library: superior performance in several benchmarks, expressive API, active community. I considered it and consciously passed. For the thesis I wanted to deliver, I decided to go all the way: zero intermediate dependency between the team and the platform. Fewer layers means less friction for a new dev to understand what’s happening, fewer surprises for an AI generating code, lower risk on a future .NET upgrade. Native Minimal APIs meet the contract with room to spare, and they protect me from carrying a decision that, three years from now, I might want to reverse.

The resulting Program.cs fits in a few lines:

Each extension method hides three or four lines of reflection: ConfigureModules walks the relevant assemblies once at startup, finds all IModule implementations, and calls ConfigureServices on each. MapEndpoints does the same dance for IEndpointDefinition, passing the WebApplication and the already-built ApiVersionSet.

Reflection only at startup, zero at runtime. Negligible initialization cost, huge maintenance gain: adding a new feature never requires touching Program.cs. A new UseCases/Feature/ is discovered on the next run, without a single line of glue code.

And here’s the detail that tends to close the argument with skeptical people in meetings: if a feature is removed from the project, it disappears without a trace in Program.cs. No orphan registration, no ghost policy pointing to a class that no longer exists, no MapPost referencing a deleted handler that will blow up with NullReferenceException in production. The structure itself protects you from the entropy that large projects accumulate over the years.

Behaviors: the pipeline that closes the loop

All of this only works because there’s a pipeline orchestrating it.

The Mediator in these examples isn’t Jimmy Bogard’s classic MediatR. It’s Martin Othamar’s Mediator, a source-generator alternative that produces dispatch at build time via Roslyn. Zero reflection at runtime, dispatching statically known to the compiler, performance close to a direct call. For a team that values zero intermediate layers on the request hot path, it makes a measurable difference, and it fits the same thesis as turning FastEndpoints down: the platform + a lightweight library with a source generator delivers what a heavy framework-library would deliver, without bringing the weight along.

Scoped is deliberate: handlers inject Scoped dependencies (database connections, repositories), and a Transient or Singleton Mediator breaks that alignment. You find out at runtime too late, in production, with random ObjectDisposedExceptions.

There are three registered behaviors, and each does one thing:

  • ValidationBehaviour, runs every IValidator<TRequest> on the request, aggregates failures, throws ValidationException. Fast-fail before the Handler touches S3.
  • PerformanceBehaviour, stopwatch. Above 1000ms logs a warning. Doesn’t block, just flags.
  • UnhandledExceptionBehaviour, catches the unexpected, logs with the request’s context, rethrows. The global KnownExceptionsHandler does the translation to Problem Details.

Three pieces. No retry, no circuit breaker, no cache in the pipeline, because those decisions belong to the Handler (or its Services), not to the pipeline. The pipeline deals with concerns that cut across every request. Feature-specific business lives in the feature.

Where this costs you

There’s no free lunch, and anyone who tells you there is, is selling a course.

Repetition across similar features. Two features uploading to different buckets are going to have similar Handlers. The temptation to create UploadServiceBase is strong. Resist until the third repetition, premature DRY in VSA breaks the independence promise.

Testability of the Minimal API endpoint. The fluent chain MapGroup…MapPost is hard to test in isolation. Accept it: what matters is testing the Handler (which is 100% testable with mocks) and the pipeline end-to-end with WebApplicationFactory. Don’t try to unit-test the endpoint itself, that’s not where the value lives.

Dependency on a Mediator. It’s not “zero external dependency”, it’s a dependency on a library with a source generator, build-time registration, and no runtime reflection. Different from heavy libraries with dynamic pipelines. If you want literally zero dependency, you can implement your own dispatcher in 80 lines. For most teams, it’s not worth it.

Feature bootstrapping lives in the feature. Forgetting to call ConfigureModules in Program.cs still takes everything down, but at least when it does, it’s obvious: the whole feature disappears, not one silent endpoint returning 500. Acceptable trade-off.

What I want you to walk away with

The key insight isn’t “use VSA.” That article has already been written by people better than me, and the third article in the series already covered why VSA produces DDD naturally.

The key insight is convention as language.

When your team has five predictable files for each use case, with clear rules about where each thing lives, format in the Validator, business in the Handler, HTTP flow in the Endpoint, authorization in the Module, three things happen at once:

PR review time drops in half. No more discussion about where to put validation or whether the endpoint should have try/catch. The convention decided, what’s left is time to discuss what matters: the business rule.

Onboarding collapses to a fraction. A new dev reads a complete feature in 15 minutes and is ready to write the sixth one following the same pattern.

AI, any of it, from Copilot to Claude, gets it right on the first try. Because the context it needs to load is one folder of five files, not the entire project tree.

VSA gives you the structure. CQRS gives you the separation. FluentValidation gives you the fast-fail. Problem Details gives you the HTTP contract. Mediator with the right lifetime gives you the pipeline.

None of these is a proprietary framework. All of this is native .NET (or close to it) + libraries recognized by the community. Whatever you choose to call your convention, that part is yours.

Don’t build a framework. Set a convention!

In the next article, I’m closing the series with something that’s been implicit the whole time: why AI, Copilot, Cursor, Claude Code, ships faster on a VSA project than on a Clean Architecture project. Spoiler: it has to do with the same thing that makes human devs ship faster. Small context, clear semantics, sharp boundaries. Human tokens and machine tokens follow the same physics.

Does your team already have a formal convention for use cases, or is every feature still a negotiation? And if you do, where does validation live, error handling, authorization? Tell me in the comments, this is the kind of exchange that’s worth more than any article.

<- Previous article: Team Topologies as a shortcut to the DDD your team didn’t know it was doing

Next in the series ->AI + VSA: why your organized slice costs fewer tokens and ships faster


메타데이터
post_id
4e6fdb22515c
slug
vsa-cqrs-without-turning-into-a-framework-the-shared-language-your-team-has-been-wanting-4e6fdb22515c
url
https://medium.com/@jean-puga/vsa-cqrs-without-turning-into-a-framework-the-shared-language-your-team-has-been-wanting-4e6fdb22515c
canonical_url
https://medium.com/@jean-puga/vsa-cqrs-without-turning-into-a-framework-the-shared-language-your-team-has-been-wanting-4e6fdb22515c
author_url
https://medium.com/@jean-puga
status
ok
fetched_at
2026-06-10 18:44:10