It’s time to review the structure of projects on .NET

Short description

Microsoft’s new Minimal API approach in .NET 6 is designed to simplify the development of web services. But it’s also creating new questions about how to organize code within projects. In a blog post, Tim Deschryver suggests a new architecture based on the idea of domain-driven APIs. Instead of dividing an application into layers with areas of code responsibility, consider grouping applications into modules or features in a separate folder based on domains. Each module has its own class to configure dependencies and register endpoints. Deschryver’s approach results in cleaner code and easier debugging, with infrastructure configurations remaining in Program.cs.

It’s time to review the structure of projects on .NET

This is a somewhat loose translation of the article “Maybe it’s time to rethink our project structure with .NET 6” from Tim Deschryver on an approach to creating services using Minimal APIs that can help us make our application architecture cleaner, simpler, and easier to maintain and develop.

The post seems to me an inspiring continuation of ideas vertical slice architecture and some answer unnecessary separation and simplicity of MediatR handlers and their call locations.

With the release of .net 6, we have a new simplified approach to quickly create services Minimal API. This article arose because with the new approach new questions arose related to the organization of code within the project.

But first, let’s see what Minimal APIs looks like.

What are Minimal APIs?

Minimal APIs really reduce configuration and code to a minimum, abandoning controllers and Startup.cs. Team dotnet new web will create a new project with one code file Program.cs:

WebApplication
│   appsettings.json
│   Program.cs
│   WebApplication.csproj

IN Program.cs top-level statements are used to configure, compile and run the program. It all takes 4 lines of code:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
app.MapGet("/", () => "Hello World!");
 
app.Run();

If you run this code, you will have a service that can receive a request and respond to it.

It looks unusual. Previously, the folder structure of a .NET Web API project consisted of a file Program.cs (with the Main method to run the API), file Startup.cs (with ConfigureServices and Configure methods to configure services and the request processing pipeline) and folders Controllers with a controller file containing application endpoints.

WebApplication
│   appsettings.json
│   Program.cs
│   Startup.cs
│   WebApplication.csproj
│
├───Controllers
│       Controller.cs

In most applications and examples I’ve seen, this structure is kept as the foundation, with new layers built on top of it as the project grows in complexity. The structure of the existing API probably looks like a variation to such a division by “technical” areas of responsibility within one project, or different layers are divided into several projects.

WebApplication
│   appsettings.json
│   Program.cs
│   Startup.cs
│   WebApplication.csproj
│
├───Configuration/Extensions
│       ServiceCollection.cs
│       ApplicationBuilder.cs
├───Controllers
│       ...
├───Commands
│       ...
├───Queries
│       ...
├───Models/DTOs
│       ...
├───Interfaces
│       ...
├───Infrastructure
│       ...

A similar structure can be seen in the dotnet-architecture/eShopOnWeb project, based on the principles of the book Architecting Modern Web Applications with ASP.NET Core and Azure.

This is an industry standard, if you follow it, even in an unfamiliar project, you can quickly find your way around such a structure. By looking at the controllers and their device, we can understand what actions the service can perform and how it communicates with other layers of the application.

But for now, the Minimal API doesn’t dictate our initial project structure. Maybe it’s time to review it? We have several options.

API in one file

The easiest way to add functionality to Minimal APIs is to just keep adding endpoints, handlers, helper methods, and configuration to the file Program.cs. But the file will quickly become bloated, and different code will be mixed in one place.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ICustomersRepository, CustomersRepository>();
builder.Services.AddScoped<ICartRepository, CartRepository>();
builder.Services.AddScoped<IOrdersRepository, OrdersRepository>();
builder.Services.AddScoped<IPayment, PaymentService>();
 
var app = builder.Build();
app.MapPost("/carts", () => {
    ...
});
app.MapPut("/carts/{cartId}", () => {
    ...
});
app.MapGet("/orders", () => {
    ...
});
app.MapPost("/orders", () => {
    ...
});
 
app.Run();

API with controllers

The second option is to return to the familiar and familiar. We can still add controllers to the project and use them. App templates even have a project with controllers left over.

WebApplication
│   appsettings.json
│   Program.cs
│   WebApplication.csproj
│
├───Controllers
│       CartsController.cs
│       OrdersController.cs

To use controllers, you must register them in your application using a method IServiceCollection.AddControllers() and map handlers and routes for them using MapControllers():

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<ICustomersRepository, CustomersRepository>();
builder.Services.AddScoped<ICartRepository, CartRepository>();
builder.Services.AddScoped<IOrdersRepository, OrdersRepository>();
builder.Services.AddScoped<IPayment, PaymentService>();
 
var app = builder.Build();
app.MapControllers();
app.Run();

Problems with the current structure of the project

This structure divides the application into layers with areas of code responsibility — routing, business logic, data storage. Adding new functionality will require modifying existing files in multiple layers and possibly creating new ones. When debugging, you have to move between numerous files and layers. The way to implement simple and complex endpoints in this case is not much different – you need to make a lot of changes to several layers of the program. Simple and complex endpoints move along the same pipeline, and simple endpoint implementations become much more complex than necessary. Unfortunately, the more code you need, the more likely you are to make a mistake.

And controllers also have a tendency to inflate over time.

Domain Driven Api

If we move from the traditional folder structure, which divides the application into horizontal layers, to a domain-oriented structure, in which the application is grouped with its domains. Each application domain is grouped into a module (or feature) in a separate folder.

The structure of a simple application using a modular approach would look something like this:

WebApplication
│   appsettings.json
│   Program.cs
│   WebApplication.csproj
│
├───Modules
│   ├───Cart
│   │      CartModule.cs
│   └───Orders
│          OrdersModule.cs

At first glance, this is a minor change. It can be a bit confusing because now it’s not obvious where to start reading the code. To understand the advantages of this structure, let’s take a closer look at the files.

The structure is similar to the Domain Layer described in the article Domain model structure in a custom .NET Standard Library.

What is a module?

The module consists of two parts:

The minimal implementation of the module is a class with two methods, the first to configure the DI container and the second to register the endpoints. It is somewhat similar to the old one Startup.cs or a new one Program.cs, but of a separate module. The main advantage: everything the module needs is isolated inside and you can quickly understand which dependencies it consumes. This will make it easier to find unnecessary code. And it will be useful when writing tests, because it will allow you to make an isolated system for modules.

public static class OrdersModule
{
    public static IServiceCollection RegisterOrdersModule(this IServiceCollection services)
    {
        services.AddSingleton(new OrderConfig());
        services.AddScoped<IOrdersRepository, OrdersRepository>();
        services.AddScoped<ICustomersRepository, CustomersRepository>();
        services.AddScoped<IPayment, PaymentService>();
        return services;
    }
 
    public static IEndpointRouteBuilder MapOrdersEndpoints(this IEndpointRouteBuilder endpoints)
    {
        endpoints.MapGet("/orders", () => {
            ...
        });
        endpoints.MapPost("/orders", () => {
            ...
        });
        return endpoints;
    }
}

To connect the module, we need to call in Program.cs two created methods from the module. When we do this, the module will be connected to the application.

var builder = WebApplication.CreateBuilder(args);
builder.Services.RegisterOrdersModule();
 
var app = builder.Build();
app.MapOrdersEndpoints();
app.Run();

This approach will save Program.cs easily and clearly separate modules and their own dependencies.

Setting up the general infrastructure (eg logging, authentication, middleware, swagger, …) of the application also remains in Program.csbecause it is used in all modules.

To learn how popular libraries can be configured, see Minimal APIs at a glance by David Fowler and MinimalApiPlayground by Damian Edwards.

To add new modules, we have to create the module class again, add the registration and configuration methods, and then call them in the Program.csbut with a little abstraction, part of the routine can be automated.

Automatic registration of modules

To automate the process of registering a new module, we will need to add a new interface. IModule. We will use this interface to find all modules that implement this interface in the application. We search for modules using reflection and register each module found in the application.

public interface IModule
{
    IServiceCollection RegisterModule(IServiceCollection builder);
    IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints);
}
 
public static class ModuleExtensions
{
    // this could also be added into the DI container
    static readonly List<IModule> registeredModules = new List<IModule>();
 
    public static IServiceCollection RegisterModules(this IServiceCollection services)
    {
        var modules = DiscoverModules();
        foreach (var module in modules)
        {
            module.RegisterModule(services);
            registeredModules.Add(module);
        }
 
        return services;
    }
 
    public static WebApplication MapEndpoints(this WebApplication app)
    {
        foreach (var module in registeredModules)
        {
            module.MapEndpoints(app);
        }
        return app;
    }
 
    private static IEnumerable<IModule> DiscoverModules()
    {
        return typeof(IModule).Assembly
            .GetTypes()
            .Where(p => p.IsClass && p.IsAssignableTo(typeof(IModule)))
            .Select(Activator.CreateInstance)
            .Cast<IModule>();
    }
}

A refactored order module that implements the interface IModule:

public class OrdersModule : IModule
{
    public IServiceCollection RegisterModules(IServiceCollection services)
    {
        services.AddSingleton(new OrderConfig());
        services.AddScoped<IOrdersRepository, OrdersRepository>();
        services.AddScoped<ICustomersRepository, CustomersRepository>();
        services.AddScoped<IPayment, PaymentService>();
        return services;
    }
 
    public IEndpointRouteBuilder MapEndpoints(IEndpointRouteBuilder endpoints)
    {
        endpoints.MapGet("/orders", () => {
            ...
        });
        endpoints.MapPost("/orders", () => {
            ...
        });
        return endpoints;
    }
}

Program.cs now uses extension methods RegisterModules() and MapEndpoints() to register all modules in the application.

var builder = WebApplication.CreateBuilder(args);
builder.Services.RegisterModules();
 
var app = builder.Build();
app.MapEndpoints();
app.Run();

With the addition of an interface IModule we get rid of the problem of gradual inflation Program.cs and we don’t give ourselves the opportunity to shoot ourselves in the foot if we forget to register the newly added module. To register a new module, it is enough to create a new class and implement the interface IModuleand that’s all.

This module-oriented approach is very similar the Carter project.

Structure of the module

The advantage of this approach is that each module becomes self-sufficient and can be developed independently.

Simple modules are easy to configure, while more complex modules remain flexible and may involve a more complex configuration procedure. For example, simple modules can be developed in a single file (what we saw above) and follow the “API in one file” approach, while complex modules can be split into multiple files:

WebApplication
│   appsettings.json
│   Program.cs
│   WebApplication.csproj
│
├───Modules
│   └───Orders
│       │   OrdersModule.cs
│       ├───Models
│       │       Order.cs
│       └───Endpoints
│               GetOrders.cs
│               PostOrder.cs

Inspired by the project ApiEndpoints Steve “ardalis” Smith. You can read more about this pattern in his article “MVC Controllers are dinosaurs – use API endpoints” or in the example dotnet-architecture/eShopOnWeb.

What other advantages does such a structure have?

A domain-based structure groups files and folders by their (sub)domains. This makes it easier to navigate and understand how and what a particular module works with. No more jumping between layers in all the folders to find the code that does what you need because everything is everywhere.

And it helps to make the application as simple as possible — not to start with abstractions of different levels just in case. That is, you should start with a simple project that contains one or more module folders. A module should start as a single file and split when it becomes difficult to navigate. If this happens, you can split the module into different files, such as extracting the endpoints into their own files. Of course, if you want to maintain uniformity, you can use the same structure in all modules. In short, the structure of your modules should reflect the simplicity or complexity of the domain.

For example, you are building an order management application. The core of the program is a complex order module, which is divided into several files. Several other helper modules contain simple CRUD operations, so they are implemented in a single file to save time.

In the example below, the module Orders is the root domain that contains all the business rules, so the endpoints are moved to the folder Endpointswhere each endpoint receives its own separate file. Module Carts – Auxiliary. It contains several simple methods and is implemented as a single file.

WebApplication
│   appsettings.json
│   Program.cs
│   WebApplication.csproj
│
├───Modules
│   ├───Cart
│   │      CartModule.cs
│   └───Orders
│       │   OrdersModule.cs
│       ├───Endpoints
│       │       GetOrders.cs
│       │       PostOrder.cs
│       ├───Core
│       │       Order.cs
│       │───Ports
│       │       IOrdersRepository.cs
│       │       IPaymentService.cs
│       └───Adapters
│               OrdersRepository.cs
│               PaymentService.cs

Project development: preparing for uncertainty

Over time, the project grows and accumulates knowledge about the subject area (domain). In a module-oriented structure, it is quite simple to turn a module into a separate service. If the module is self-contained, you can copy the module folder into a separate project or new application. In principle, the module can be considered as a plug-in that is easy to move.

It is also easy to remove and combine modules: in the first case, it is enough to delete the module folder, and in the second case, to move clearly organized files or code.

Conclusions

This approach to code organization is my attempt to continue the philosophy of Minimal APIs and reduce the code to the necessary minimum. I want the service to be simple and easy to maintain, while retaining the ability to expand.

The goal is to reduce the coherence and complexity of the different parts of the program. Instead, the application should be divided into a core and modules that are self-contained and independent.

Not every module needs complex configuration. By dividing the application into domains, it becomes easier to separate each part from the general configuration block. Inside the module, we still have the flexibility and ability to create a different structure. The ultimate goal of this approach is to make it easier to start a new project or join an existing one, and to make support simpler.

When I compare the current project structure to the modular structure, it becomes apparent how much junk can be thrown away. On the one hand, a single file with an endpoint that is easy to find, on the other hand, an endpoint whose logic falls down several layers into several folders. Often there are some additional transformations between these layers. For example, my current processing flow for any request looks something like this:

 Controller |> MediatR |> Application |> Domain |> Repository/Service

Compare this with the approach of this article:

Endpoint (|> Domain) |> Service

I understand that these layers did not appear for nothing, but times have changed. Just a few years ago, these layers were critical to testing an application, but in the last few years we’ve seen a revolution in functional testing, which you can read about in How to Test C# Web API . This is another important point in favor of simplifying the code as much as possible and trying to reduce the number of interfaces, creating them only when they are really needed (for example, when interacting with a third-party service). With Minimal APIs, it’s easier to construct objects using the new operator instead of relying on the DI container.

We have only looked at the Minimal APIs project structure, and in the examples all files are included in a single project. Following this architecture, you can separate the Core/Domain layer and the Infrastructure layer into different projects. Will you do it or not? Depends on the size of the project and I think it would be nice to talk about it to be on the same page. Personally, I do not have a clear opinion on this.

The main thing is not to complicate, just make the project simple.

Related posts