"Should I use MVC or Minimal APIs?" is usually the wrong question, and the answer is usually "both". They are not competing frameworks — they are two endpoint styles inside the same ASP.NET Core pipeline, and a well-structured business application benefits from each in the place it fits. This part of the guide assembles the architecture the previous parts described piece by piece: MVC for pages, Minimal APIs for operations, CQRS handlers for rules, EF Core for persistence, and vertical slices as the organizing principle.
MVC vs Minimal APIs: an honest comparison
The two styles differ in ceremony, structure, and the problems they solve best:
- MVC is convention-rich. Controllers, actions, filters, model binding, Razor views, layouts, and tag helpers all come as a package. That package is exactly what a screen-heavy application needs — and excess weight for a single JSON operation.
- Minimal APIs are convention-light. An endpoint is a route plus a delegate, with the dependencies it needs injected directly. Perfect for focused data operations and small services; inconvenient for rendering pages.
- MVC shines when presentation matters. Server-rendered navigation, layouts, sections, and collocated scripts are first-class concerns the MVC pipeline understands.
- Minimal APIs shine when the contract matters. One route, one request type, one response envelope — the endpoint file reads like a list of the feature's operations.
The decisive point is that both styles share routing, dependency injection, authorization, rate limiting, and filters. Mixing them is not a hack; it is supported composition.
We are not replacing MVC with Minimal APIs
This sentence deserves to be stated plainly because a lot of commentary implies otherwise. Minimal APIs were added to ASP.NET Core for scenario fit, not to correct a mistake in MVC. In this architecture:
- MVC controllers route pages. They receive browser navigations, select Razor views, and hand identifiers to collocated scripts. They do not own business rules.
- Minimal API endpoints expose operations. They receive JSON, call one handler, and translate the result into an HTTP status.
- Handlers own the use case. Whether they are called from a controller action or an endpoint — or both — the rules live in exactly one class.
The result is a clean division: pages stay pages, operations stay operations, and business rules stop being duplicated between the two.
What Vertical Slice Architecture means
Traditional layering organizes code by technical concern: all controllers in one project, all services in another, all data access in a third. The consequence is that a single feature change touches every layer and every folder.
Vertical Slice Architecture organizes by feature instead. Each feature owns everything it needs, top to bottom:
Areas/Main/
├── Todo/ — one vertical slice
│ ├── Controllers/ — page routing
│ ├── Cqrs/ — commands, queries, validators
│ ├── Endpoints/ — /api/todo operations
│ └── Views/ — Razor views + collocated JS
├── Currency/ — another slice, same shape
│ ├── Controllers/
│ ├── Cqrs/
│ ├── Endpoints/
│ └── Views/
└── Customer/ — another slice, same shape
└── ...
When a request for "add a Currency export feature" arrives, every file that changes lives under Currency/. Nothing about Todo, Customer, or the shared infrastructure is involved. The slice boundary is physical, which is why both humans and AI coding assistants can work inside it safely.
A slice from top to bottom
Part 7 walked the Todo slice in detail; the Currency slice follows the identical shape. The two request paths — page and data — apply to every slice:
Page request
Browser ──> /Main/Currency/Index ──> CurrencyController
──> View("~/Areas/Main/Currency/Views/Index.cshtml")
──> Razor HTML + Index.cshtml.js in the browser
Data request
Browser ──> POST /api/currency ──> CurrencyEndpoint
──> CreateCurrencyHandler ──> Validator
──> AppDbContext ──> SQL ──> ApiResponse<T> back to the browser
The endpoint file stays trivial because the handler owns the work:
public static void Map(IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/currency").RequireAuthorization();
group.MapGet("/", async (GetCurrencyListHandler h, int page = 1,
CancellationToken ct) =>
Results.Ok(await h.HandleAsync(new GetCurrencyListQuery { Page = page }, ct)));
group.MapPost("/", async (CreateCurrencyCommand cmd,
CreateCurrencyHandler h, CancellationToken ct) =>
Results.Created($"/api/currency/{await h.HandleAsync(cmd, ct)}", cmd));
}
And the handler is where the slice's rules can be found, tested, and reused by any surface:
public sealed class CreateCurrencyHandler
{
private readonly AppDbContext _db;
public CreateCurrencyHandler(AppDbContext db) => _db = db;
public async Task<int> HandleAsync(CreateCurrencyCommand command,
CancellationToken ct)
{
var entity = new Currency
{
Code = command.Code.ToUpperInvariant(),
Name = command.Name,
Rate = command.Rate
};
_db.Currencies.Add(entity);
await _db.SaveChangesAsync(ct);
return entity.Id;
}
}
One feature, two surfaces, one rule book. If a mobile client arrives later, it calls the same endpoints; if a page needs server-rendered data, it can call the same handlers. Nothing is duplicated, and nothing needs a second implementation to stay consistent.
Direct handler invocation vs MediatR, inside slices
Both wiring styles work inside this architecture.
- Direct invocation (used above). Endpoints and controllers inject handler classes and call them. The slice is self-contained, debugging is straightforward, and the call graph is visible in code.
- MediatR-style dispatching. Handlers implement a common request interface and are resolved through a mediator. You gain pipeline behaviors — cross-cutting validation, logging, transactions — applied uniformly to every command.
The choice is per-team, not per-architecture. What matters is that the use case boundary exists either way: one class named after one operation, owned by one slice. Everything else is wiring preference.
Why one project instead of microservices
Vertical slices plus a single deployable project produce what is often called a modular monolith. For most business applications it beats splitting into services:
- One deployment. No service mesh, no distributed tracing budget, no version skew between services.
- One transaction. Writes that must stay consistent remain inside a single database transaction.
- One codebase. Refactoring a boundary is moving files, not renegotiating a network contract.
- Future optionality. A slice that genuinely needs to become a service already has its own folder, endpoints, and handlers — extraction becomes mechanical rather than archaeological.
EF Core completes the picture: one AppDbContext per application (or per database boundary), entities in a shared data project, and handlers that query and persist through it. The slice owns its rules; the context owns the mapping.
Trade-offs — and when not to use this
No architecture is free. Be honest about the costs:
- More files per feature. A simple CRUD screen has a controller, view, script, endpoint, handler, validator, request, and response types. For a throwaway tool, that is overhead.
- Discipline required. Slices stay independent only if shared code is genuinely shared. A growing "Common" folder is the first sign of a slice boundary collapsing.
- Not for heavy domain modeling alone. If the core problem is complex domain behavior — pricing engines, scheduling algorithms — you may want richer domain models inside slices; the folder pattern does not replace domain design.
- Team familiarity. The architecture is easy to explain and easy to follow, but it works best when everyone agrees on the five file kinds and their responsibilities.
Getting started checklist
- Create one area folder per feature and one
Controllers/Cqrs/Endpoints/Viewsstructure inside it. - Move the DbContext out of controllers first; then extract one query and one command as the pattern example.
- Register handlers in DI and map endpoints in a per-feature extension method.
- Keep the page path and the data path separate in every screen.
- Extend by copying the shape of the closest existing slice, not by inventing a new one.
The last point is the one that compounds. Every slice that follows the pattern makes the next one faster to write, easier to review, and safer for AI assistance — which is exactly where the final part of this guide picks up: From ASP.NET Core MVC Tutorial to Production-Ready Business Software.
Key Takeaways
- MVC and Minimal APIs coexist: MVC routes pages, endpoints expose operations.
- Vertical Slice Architecture organizes by feature, with one folder owning controllers, handlers, endpoints, and views.
- Handlers are the single source of truth for rules and can be invoked directly or through a dispatcher.
- A modular monolith keeps deployment and transactions simple while leaving extraction possible later.
- The architecture costs more files per feature; it pays back in isolation, testability, and consistency.
FAQ
Is Vertical Slice Architecture the same as microservices?
No. Vertical slices are an organizational pattern inside a codebase; microservices are a deployment pattern. A single-project monolith with vertical slices is the common starting point, and slices can be extracted into services later if a real need appears.
Do I have to use Minimal APIs for every operation?
No. Some operations make more sense on an MVC action — especially when they return a view. The rule is about responsibility: pages through controllers, data contracts through endpoints, rules in handlers.
Where do shared concerns live?
Cross-cutting infrastructure — authentication setup, rate limiting policies, the DbContext, file storage, email — lives in shared projects and is injected into slices. What must not live in shared code is one feature's business logic.
Can I adopt this incrementally?
Yes, and you should. Pick one feature, apply the slice structure, extract its handlers and endpoints, and leave the rest of the application alone. The pattern spreads by imitation, not by big-bang migration.
