Structuring .NET Minimal APIs in a cleaned and well maintainable way

Pasindu Prabhashitha
7 min readApr 19, 2024

In the .NET world, a minimal API is a simplified way to create web services with less code and complexity. With minimal APIs, developers can build functional web APIs using just a few lines of code, focusing on the essential parts of their project. It’s a user-friendly approach in ASP.NET Core that reduces unnecessary details, making it easier for beginners to grasp and work on their API projects without getting overwhelmed by boilerplate code.

However, for beginners, structuring minimal APIs in a project can often be frustrating. Therefore, in this article, I aim to provide my personal opinions on how you can structure your minimal API code. While there are many ways to approach this, the suggestions presented here reflect my own perspective. I welcome your thoughts and comments on this blog post.

To start this article, let’s assume we have a project that manages the endpoints related to products.

If you create a new .NET Web API project, inside your Program.cs file by default, they have created a minimal API endpoint, something like the example below.

But since this is inside the Program.cs file, this approach won’t be scalable, as when you add more and more endpoints, the Program.cs file starts to grow. Therefore, we should move this endpoint into a separate file. Therefore we first we should move this out from the program.cs file.

To do that, in the root directory, I create a separate folder called ‘features’. I’m choosing to create a ‘features’ folder assuming that I have multiple features like Product Management, Orders Management, and Auth Management, etc.

Inside the ‘features’ folder, I create another subfolder called ‘Products’ and within that, create a new file called ‘Routes.cs’. Inside this file, I’m going to place my Products-related routes. To achieve this, we will have to create an extension method. This is because otherwise, we don’t have access to the WebApplication instance and in order to register these routes to the application we need the access to the WebApplication instance. To do that, create a static method called ‘RegisterProductsRoutes’ and pass this WebApplication app as the parameter. Your code should look like the example below.

namespace StructuringMinimalApis.Features.Products
{
public static class Routes
{
public static void RegitserProductsRoutes(this WebApplication app)
{

}
}
}

Now, inside this method, you can the minimal API’s MapPost, MapGet, MapPut, and MapDelete methods using the app. Therefore, since I need to register a Products create endpoint, my endpoint will look like the example below.

namespace StructuringMinimalApis.Features.Products
{
public static class Routes
{
public static void RegitserProductsRoutes(this WebApplication app)
{
app.MapGet("/products", () => Results.Ok(new[]
{
new Product { Id = 1, Name = "Product 1" },
new Product { Id = 2, Name = "Product 2" },
new Product { Id = 3, Name = "Product 3" },
}));
}
}
}

But you’ll have to take one additional step to get this to work. That means you need to go to your Program.cs file and add the following code.

app.RegitserProductsRoutes();

So when you save the changes and run the server, you will see your new endpoint has been registered. However, this approach has one overhead, which is that every time a new feature is added, you need to include this method in your Program.cs file, potentially causing it to grow. To avoid this, we can take one step further.

Doing an assembly search to autmatically register the endpoints

In this approach, I will create an interface which we can inherit with our routes classes, and after that, perform an assembly search to register the endpoints automatically. I know that sounds complicated, but it’s easier than you think.

To do that, I create a new folder named “Interfaces”. Inside this folder, I create an interface named IEndpointDefinition. The IEndpointDefinition interface should contain a single method named DefineEndpoints that takes a WebApplication instance as an argument. This method will be responsible for defining the endpoints for a particular feature.

Here’s how the IEndpointDefinition interface looks like:

namespace StructuringMinimalApis.Interfaces
{
public interface IEndpointDefinition
{
void DefineEndpoints(WebApplication app);
}
}

This interface provides a standard method for each feature to define its endpoints. For each feature, there will be a class that implements IEndpointDefinition. In the DefineEndpoints method of this class, specific endpoints for that feature will be defined. Subsequently, in the Routes file, I will inherit that class with this interface. Once you do that, the previous routes file will look like this:

namespace StructuringMinimalApis.Features.Products
{
public class Routes : IEndpointDefinition
{
public void DefineEndpoints(WebApplication app)
{
// Define product-related endpoints here
}
}
}

Now, we should write an extension method that searches for classes inherited from this interface and registers them as routes.

To do that at the root level, create a folder called ‘extensions’. Inside that folder, create a file called ‘MinimalApiExtensions.cs’. After that you can write that extension method as follows.

using StructuringMinimalApis.Interfaces;

namespace StructuringMinimalApis.Extensions
{
public static class MinimalApiExtensions
{
public static void RegisterEndpointDefinitions(this WebApplication app)
{
var endpointDefinitions = typeof(Program).Assembly
.GetTypes()
.Where(t => t.IsAssignableTo(typeof(IEndpointDefinition)) && !t.IsAbstract
&& !t.IsInterface)
.Select(Activator.CreateInstance).Cast<IEndpointDefinition>();

foreach (var endpointdef in endpointDefinitions)
{
endpointdef.RegisterEndpoints(app);
}
}
}
}

A brief description about this code is it defines an extension method for WebApplication called RegisterEndpointDefinitions. When called, it searches for classes in the same assembly as Program that implement IEndpointDefinition. It instantiates these classes and calls their RegisterEndpoints method with the WebApplication instance as an argument.

Now we have written the extension method. As last step you should register this RegisterEndpointDefinitions method inside the Program.cs file. In order to do that go to the Program.cs file and register it as below.

app.RegisterEndpointDefinitions();

Once you do that, you won’t need to register the endpoints one by one in the Program.cs file. Instead, once you inherit your Routes class with the IEndpointDefinition interface we created, the application itself will search through the assembly and register your routes. In this way, you will be able to achieve a scalable routing layer inside your application.

Grouping endpoints inside Swagger Documentation

Another structuring technique I wanted to share is how you can organize your endpoints in Swagger based on categories.

Let’s consider the products routes file. Below, you can add the method called WithTagsat the end, and with that method, you can specify the category the endpoint belongs to. In this case, the category is ‘Products’. Once you do that and generate the Swagger documentation, you will see your endpoints categorized under the ‘Products’ category. This way, you can have organized Swagger documentation.

        public void RegisterEndpoints(WebApplication app)
{
app.MapGet("/products/with-i-endpoints-definition", () => Results.Ok(new[]
{
new Product { Id = 1, Name = "Product 1" },
new Product { Id = 2, Name = "Product 2" },
new Product { Id = 3, Name = "Product 3" },
})).WithTags("Products");
}

This is how the swagger looks like now.

As you can see, there’s a group called Products, and all of your endpoints will be listed under it. However, when you have several endpoints like this, you will need to call WithTags at the end of each endpoint. To simplify this, you can write the code as shown below.

        public void RegisterEndpoints(WebApplication app)
{
var products = app.MapGroup("/api/products").WithTags("Products");

products.MapGet("/with-i-endpoints-definition", () => Results.Ok(new[]
{
new Product { Id = 1, Name = "Product 1" },
new Product { Id = 2, Name = "Product 2" },
new Product { Id = 3, Name = "Product 3" },
}));

products.MapGet("/with-i-endpoints-definition/{id}", (int id) =>
{
var product = new Product { Id = id, Name = $"Product {id}" };
return Results.Ok(product);
});
}
}

Here, you first create a group with the path of your endpoints, and for that group, you call the WithTags method. Once you do that, instead of directly mapping the endpoints to the app, you map them to that group.

In addition to the WithTags method, there are some other methods that you can use to add more metadata to your Swagger documentation. For example, withDescription. I highly encourage you to experiment with them.

Separating routes and implementations

To further simplify this code, you can keep the endpoints’ definition in this file and move the implementation to another file. To do that create two files called ProductsEndpointDefinition.cs and ProductsEndpointsHandler.cs Then you can seprate the routes and implementation like below.

ProductsEndpointDefinition.cs

using MaterialFlow.Api.Features.Products;
using StructuringMinimalApis.Interfaces;

namespace StructuringMinimalApis.Features.Products.WithIEndpointDefinition
{
public class ProductEndpointsDefintion : IEndpointDefinition
{
public void RegisterEndpoints(WebApplication app)
{
var products = app.MapGroup("/api/products").WithTags("Products");

products.MapGet("/with-i-endpoints-definition", ProductEndpointsHandler.GetProducts);
products.MapGet("/with-i-endpoints-definition/{id}", ProductEndpointsHandler.GetProduct);
}
}
}

ProductsEndpointsHandler.cs

using StructuringMinimalApis.Models;

namespace MaterialFlow.Api.Features.Products;

public class ProductEndpointsHandler
{
public static async Task<List<Product>> GetProducts()
{
var products = new List<Product>
{
new Product { Id = 1, Name = "Product 1" },
new Product { Id = 2, Name = "Product 2" },
new Product { Id = 3, Name = "Product 3" },
};

return products;
}

public static async Task<Product> GetProduct(int id)
{
var product = new Product { Id = id, Name = $"Product {id}" };
return product;
}
}

Now there’s a clear separation between your routes definitions and routes implementations. So, you will end up with a cleaned, maintainable routing layer. Those were the key points I wanted to demonstrate in this article. If you need access to this code, you can check out the GitHub Repo through the following link. Feel free to post your feedbacks or questions about this article as a comment.

Repo: https://github.com/pasindu-pr/structuring-minimal-apis

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Pasindu Prabhashitha
Pasindu Prabhashitha

Written by Pasindu Prabhashitha

Full Stack Engineer specializing in .NET, React, and Azure | pasinduprabhashitha.com

No responses yet

Write a response