Some decisions become second nature.

Open Visual Studio, create an ASP.NET Core project, and watch the Controllers/ folder appear. Create a class, inherit from ControllerBase, add the [ApiController] attribute, inject services into the constructor, and write the routing attributes. Return ActionResult<T>.

These are conventions we’ve learnt, internalised, and naturally replicate from one project to the next without really questioning them.

One afternoon, between meetings, I had 45 minutes to spare and a curiosity that had been niggling at me for a while. We’d just written a fairly standard new endpoint, and I decided to rewrite it using the Minimal API, just to see what it actually looked like in practice.

45 minutes later, the endpoint was up and running. Here’s what I took away from it.

WHAT IS MINIMAL API, IN A NUTSHELL?

A way of writing ASP.NET Core endpoints without controllers, without inheritance, and without routing attributes. The central idea is to reduce the framework’s requirements to the bare essentials, so you can focus on what really matters: the business logic.

The rest of this article does not seek to convince you that this is better. It shows what this actually changes, on a real endpoint, with authentication, validation, logging and testing. It’s up to you to make up your own mind.

 

THE FIRST THING THAT CHANGES: PROGRAM.CS

In the version with controllers, Program.cs looks like this:

 

 

 

 

 

 

 

MapControllers()lists all the application’s controllers on a single line. This is handy, but it’s also a bit of a black box. To find out what the API actually exposes, which routes exist, which ones are secure, and which ones apply validation, you have to open each controller one by one.

With Minimal API, the situation is different:

 

 

 

 

 

 

 

 

 

 

 

 

Routes, security, validation filters: everything is visible in one place, without having to dig through the code. This makes the architecture much clearer, and it changes the way you approach a project, whether you’re starting it from scratch or picking it up again after several months. The full code is available on GitHub.

The change to CreateBuilder to CreateSlimBuilder is also worth a closer look. It’s not just a shorter alias. CreateBuilder initialises a complete pipeline: XML formatters, advanced configuration providers, and a range of features that a small API generally does not require.CreateSlimBuildertakes the opposite approach: it starts with only the bare essentials, and you explicitly add whatever else you need.

This results in a measurable improvement in start-up times, particularly in containerised environments, but above all it sends a clear signal: this application includes only what it needs.

In practical terms, certain common features are not included by default and must be explicitly enabled if required. Kestrel’s HTTPS configuration, for example:

 

 

Or route constraints using regular expressions, which some projects use to validate the format of route parameters:

 

 

 

 

But the readability of  Program.cs is just the tip of the iceberg. What really changes is what lies beneath: the way the business logic is organised and expressed.

 

WHAT THE CONTROLLER HIDES

Here is the structure of the controller we replaced:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

Before reaching the first line of business logic, you have to go through the inheritance from ControllerBase, the routing and documentation attributes, and the constructor declaration with all the controller’s dependencies.

What is less obvious is that the constructor injects all dependencies for all the controller’s endpoints. IPaymentService and IValidator are injected even for GetOrders, which does not need them. This implicit coupling goes unnoticed when the controller is small, but it becomes a source of confusion as the number of endpoints increases.

The equivalent handler declares exactly what it needs, and nothing more:

 

 

 

 

 

 

 

 

 

 

 

When reading this signature, you can immediately see what the handler consumes, without having to go back to the constructor or read the body of the method. The OpenAPI documentation and security information are no longer scattered across class and method attributes — they are declared in the same place as the route, within the same reading flow.

Note also the use of TypedResults instead of Results. The difference is subtle but useful: each return branch has a specific type known at compile-time. TypedResults.NotFound() returns a NotFound, TypedResults.Ok(...)  returns an Ok<OrderConfirmationDto>. This detail becomes particularly significant, as we shall see, when it comes to testing.

 

VALIDATION

In the controller, validation is handled inline, mixed in with the business logic:

 

 

 

 

 

 

 

 

It’s not bad code. But validating a request and processing an order are two different things, and mixing them in the same method makes the code harder to read and reuse. In a controller with multiple endpoints, this block is repeated, with subtle variations from one endpoint to the next.

Minimal API offers a cleaner alternative with IEndpointFilter. Validation is extracted into a dedicated class, reusable on any endpoint:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

And on the endpoint, a single line is all it takes:

 

 

 

The endpoint no longer handles validation. It receives a request that has already been validated, and the code that follows focuses solely on what matters: the business logic.

TESTING

If you’ve looked closely at the code, you may have noticed something that’s a bit of a concern: the business logic is directly in Program.cs This is handy to get started, but it’s not where it should be in a real project.

We’re going to extract it into a dedicated file:

 

 

 

The routing remains in Program.cs, whilst the business logic goes into OrderEndpoints.cs And this separation brings an immediate benefit: the handlers become static methods that can be tested directly, without the need to set up a full HTTP pipeline.

 

 

 

 

 

 

 

 

 

The equivalent test on the controller is a different story:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

The ControllerContext, the DefaultHttpContext, the mock of the validator even when the test scenario has nothing to do with validation — all of that is just noise. And thanks to TypedResults, assertions are straightforward: the compiler knows the exact type of each return branch, without any casting or navigating through an ActionResult<T>.

 

WHAT IT DOESN’T REPLACE

Minimal API is not a one-size-fits-all solution, and it would be a mistake to present it as such.

On an existing codebase with dozens of controllers already in place, migrating to Minimal API is rarely a good idea. The cost of migration far outweighs the benefit, and introducing two coding styles into the same project creates more confusion than anything else.

Within a team, the transition requires less a skills upgrade than an agreement on conventions. It is not a technical barrier, but it remains a collective decision that deserves to be made together rather than left to individual initiative.

Finally, projects that rely heavily on IActionFilter for complex cross-cutting concerns will need to reassess their approach, as the Minimal API equivalent, IEndpointFilter, does not cover exactly the same use cases.

 

CONCLUSION

Rewriting this endpoint in Minimal API didn’t take 45 minutes because it’s simple. It took 45 minutes because the framework no longer puts anything between the problem and its solution.

It’s not a question of fewer lines of code or better performance. It’s what the code says about itself: what its dependencies are, what it validates, how it’s secured. With controllers, some of these answers are buried in implicit conventions. With Minimal API, they’re there, explicit, in one place.

Is it suitable for all projects and all teams? No. But for a new project or a new service, it would be a shame not to give it a try.

The code is on GitHub. Both versions run with the same configuration. The best way to form an opinion is still to compare them. 🙂