Как стать автором
Обновить
Контур
Делаем сервисы для бизнеса

Пришло время пересмотреть структуру проектов на .NET

Уровень сложностиПростой
Время на прочтение11 мин
Количество просмотров18K
Автор оригинала: Tim Deschryver

Это — немного вольный перевод статьи "Maybe it's time to rethink our project structure with .NET 6" от Tim Deschryver про подход к созданию сервисов с помощью Minimal APIs, который может помочь нам сделать архитектуру приложения более чистой, простой и легкой в поддержке и развитии.

Пост кажется мне вдохновляющим продолжением идей vertical slice architecture и некоторым ответом излишней разделенности и несвязности обработчиков MediatR и мест их вызова.

C релизом .net 6 у нас появился новый упрощенный подход для быстрого создания сервисов  Minimal APIs. Эта статья появилась потому, что с новым подходом появились новые вопросы, связанные с организацией кода внутри проекта.

Но давайте вначале посмотрим, как выглядит Minimal APIs.

Что такое Minimal APIs

Minimal APIs правда сводят конфигурацию и код к минимуму, отказываясь от контроллеров и Startup.cs. Команда dotnet new web создаст новый проект с одним файлом кода  Program.cs:

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

В Program.cs используется top-level statements для настройки, сборки и запуска приложения. Всё занимает 4 строки кода:

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

Если запустить этот код, то у вас будет сервис, который умеет принимать запрос и отвечать на него.

Это выглядит непривычно. Раньше структура папок проекта .NET Web API состояла из файла Program.cs (с методом Main для запуска API), файла Startup.cs (с методами ConfigureServices и Configure для настройки сервисов и пайплайна обработки запросов) и папки Controllers с файлом контроллера, содержащим эндпоинты приложения.

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

В большинстве приложений и примеров, которые я видел, эта структура сохраняется и служит основой, а новые слои строятся поверх нее по мере роста проекта и сложности. Структура существующего API, вероятно, выглядит вариацией к такому разделению по "техническим" зонам ответственности в рамках одного проекта, либо разные слои разделяются на несколько проектов.

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

Подобную структуру можно увидеть в проекте dotnet-architecture/eShopOnWeb, основанном на принципах из книги Architecting Modern Web Applications with ASP.NET Core and Azure.

Это отраслевой стандарт, если следовать ему, то даже в незнакомом проекте по такой структуре можно быстро сориентироваться. Посмотрев на контроллеры и их устройство, мы сразу можем понять, какие действия умеет делать сервис и как он общается с другими уровнями приложения.

Но теперь Minimal API не диктует нам первоначальную структуру проекта. Может быть, самое время пересмотреть её? У нас есть несколько вариантов.

API в одном файле

Самый простой способ добавить функциональность в Minimal APis — это просто продолжить добавлять эндпоинты, обработчики, вспомогательные методы и конфигурацию в файл Program.cs. Но файл быстро раздуется, а разный код будет смешан в одном месте.

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 с контроллерами

Второй вариант — вернуться к знакомому и привычному. Мы всё ещё можем добавить контроллеры в проект и использовать их. В шаблонах приложений даже остался проект с контроллерами.

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

Чтобы использовать контроллеры, нужно зарегистрировать их в приложении с помощью метода IServiceCollection.AddControllers() и сделать маппинг обработчиков и маршрутов для них с помощью 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();

Проблемы текущей структуры проекта

Такая структура делит приложение на слои по зонам ответственности кода — маршрутизация, бизнес-логика, хранение данных. Чтобы добавить новую функциональность, придётся изменить существующие файлы в нескольких слоях и, возможно, создать новые. Во время отладки вам приходится перемещаться между многочисленными файлами и слоями. Способ реализации простых и сложных эндпоинтов в этом случае не сильно отличается — нужно внести много изменений в нескольких слоях приложения. Простые и сложные эндпоинты движутся по одному конвейеру, а реализация простого эндпоинта становятся значительно сложнее, чем необходимо. К сожалению, чем больше кода требуется, тем выше вероятность совершить ошибку.

А ещё контроллеры тоже имеют тенденцию раздуваться со временем.

Domain Driven Api

Что если мы перейдем от традиционной структуры папок, разделяющей приложение на горизонтальные слои, к доменно-ориентированной структуре, в которой приложение группируется по его доменам. Каждый домен приложения сгруппирован в модуль (или фичу) в отдельной папке.

Структура простого приложения, использующего модульный подход, будет выглядеть примерно так:

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

На первый взгляд это незначительное изменение. Оно может немного запутать, потому что теперь неочевидно, откуда начинать чтение кода. Чтобы понять преимущества этой структуры, посмотрим подробно на файлы.

Структура похожа на Domain Layer, описанный в статье Domain model structure in a custom .NET Standard Library.

Что такое модуль

Модуль состоит из двух частей:

  • Внутренней логики и обработчиков.

  • Методов подключения модуля к проекту.

Минимальная реализация модуля — это класс с двумя методами, первый для настройки DI-контейнера и второй для регистрации эндпоинтов. Это чем-то похоже на старый Startup.cs или на новый Program.cs, но для отдельного модуля. Основное преимущество: всё, что нужно модулю, изолировано внутри, и можно быстро понять, какие зависимости он потребляет. Это облегчит поиск ненужного кода. И будет полезно при написании тестов, потому что позволит сделать изолированную систему для модулей.

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;
    }
}

Чтобы подключить модуль, нам нужно вызвать в Program.cs два созданных метода из модуля. Когда мы это сделаем, модуль будет подключен к приложению.

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

Такой подход позволит сохранить Program.cs простым и четко разделить модули и их собственные зависимости.

Настройка общей инфраструктуры (например, логирование, аутентификация, мидлвары, swagger, …) приложения также остается в Program.cs, потому что она используется во всех модулях.

Чтобы узнать, как можно настроить популярные библиотеки, посмотрите Minimal APIs at a glance by David Fowler и MinimalApiPlayground by Damian Edwards.

Чтобы добавить новые модули, мы должны снова создать класс модуля, добавить методы регистрации и настройки, а затем вызвать их в Program.cs, но с помощью небольшой абстракции часть рутины можно автоматизировать.

Автоматическая регистрация модулей

Чтобы автоматизировать процесс регистрации нового модуля, нам понадобится добавить новый интерфейс IModule. Этот интерфейс мы будем использовать для поиска всех модулей, реализующих данный интерфейс в приложении. Мы ищем модули с помощью рефлексии и регистрируем каждый найденный модуль в приложении.

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>();
    }
}

Отрефакторенный модуль заказов, реализующий интерфейс 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 теперь использует extension-методы RegisterModules() и MapEndpoints() для регистрации всех модулей в приложении.

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

С добавлением интерфейса IModule мы избавляемся от проблемы постепенного раздувания Program.cs и не даем возможности выстрелить себе в ногу, если забудем зарегистрировать свежедобавленный модуль. Чтобы зарегистрировать новый модуль, достаточно создать новый класс, реализовать интерфейс IModule, и все.

Этот модульно-ориентированный подход очень похож на проект Carter.

Структура модуля

Преимущество такого подхода заключается в том, что каждый модуль становится самодостаточным и может развиваться независимо.

Простые модули настраиваются просто, а более сложные модули сохраняют гибкость и могут включать более сложную процедуру настройки. Например, простые модули могут разрабатываться в одном файле (то, что мы видели выше) и следовать подходу "API в одном файле", в то время как сложные модули могут быть разделены на несколько файлов:

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

Вдохновлено проектом ApiEndpoints Стива "ardalis" Смита. Более подробно об этом паттерне можно прочитать в его статье "MVC Контроллеры это динозавры — используйте API эндпоинты" или в примере dotnet-architecture/eShopOnWeb.

Какие ещё преимущества есть у такой структуры?

Структура на основе доменов группирует файлы и папки по их (под)доменам. Это облегчает навигацию и понимание того, как и с чем работает конкретный модуль. Больше не нужно прыгать между слоями по всем папкам, чтобы найти код, который делает то, что вам нужно, потому что всё находится везде.

И ещё это помогает сделать приложение максимально простым — не начинать с абстракций разных уровней на любой случай. То есть, вы должны начать с простого проекта, содержащего одну или несколько папок с модулями. Модуль должен начинаться как один файл и разделяться, когда в нем становится трудно ориентироваться. Если это произойдет, вы можете разделить модуль на разные файлы, например, извлечь эндпоинты в их собственные файлы. Конечно, если вы хотите сохранить единообразие, то можете использовать одну и ту же структуру во всех модулях. Короче говоря, структура ваших модулей должна отражать простоту или сложность домена.

Например, вы создаете приложение для управления заказами. Ядром приложения является сложный модуль заказов, который разделен на несколько файлов. Несколько других вспомогательных модулей содержат простые CRUD-операции, поэтому они реализованы в одном файле, чтобы сократить время.

В приведенном ниже примере модуль Orders является основным доменом, содержащим все бизнес-правила, поэтому эндпоинты перемещаются в папку Endpoints, где каждый эндпоинт получает свой отдельный файл. Модуль Carts — вспомогательный. Он содержит несколько простых методов, и реализован в виде одного файла.

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

Развитие проекта: готовимся к неопределенности

Со временем проект растёт и накапливает знания о предметной области (домене). В модуль-ориентированной структуре достаточно просто превратить модуль в отдельный сервис. Если модуль является самодостаточным, то можно скопировать папку модуля в отдельный проект или новое приложение. В принципе, модуль можно рассматривать как плагин, который легко переместить.

Удалять и объединять модули тоже несложно: в первом случае достаточно удалить папку модуля, а во втором — переместить понятным образом организованные файлы или код.

Выводы


Такой подход к организации кода — это моя попытка продолжить философию Minimal APIs и свести код к необходимому минимуму. Я хочу, чтобы сервис стал простым и удобным в поддержке, сохранив возможности для расширения.

Цель состоит в том, чтобы уменьшить связность и запутанность разных частей приложения. Вместо этого приложение должно быть разделено на ядро и модули, которые являются самодостаточными и независимыми.

Не каждому модулю нужна сложная конфигурация. Разделив приложение на домены, становится проще отделять каждую часть от общего блока настройки. Внутри модуля у нас остается гибкость и возможность создавать разную структуру. Конечная цель такого подхода в том, чтобы было легче начать новый проект или присоединиться к существующему, и сделать поддержку более простой.

Когда я сравниваю текущую структуру проекта и структуру с разделением на модули, то становится очевидно, как много хлама можно выкинуть. С одной стороны, один файл с эндпоинтом, который легко найти, с другой стороны эндпоинт, логика которого проваливается на несколько слоев в нескольких папках. Зачастую между этими слоями ещё есть и некоторые дополнительные преобразования. Например, мой текущий поток обработки для любого запроса выглядит примерно так:

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

Сравните это с подходом из этой статьи:

Endpoint (|> Domain) |> Service

Я понимаю, что эти слои появились не просто так, но времена изменились. Всего пару лет назад такие слои были крайне важны для тестирования приложения, но в последние несколько лет мы наблюдаем революцию функциональных тестов, о чем можно почитать в статье "Как тестировать C# Web API". Это еще один важный момент в пользу того, чтобы максимально упростить код и постараться сократить количество интерфейсов, создавая их только тогда, когда они действительно нужны (например, при взаимодействии со сторонним сервисом). С Minimal APIs стало проще конструировать объекты явно с помощью оператора new вместо того, чтобы полагаться на DI-контейнер.

Мы рассмотрели только структуру проекта Minimal APIs, и в примерах все файлы включены в один проект. Следуя этой архитектуре, вы по-прежнему можете выделить слой Core/Domain и слой Infrastructure в разные проекты. Будете вы это делать или нет? Зависит от размера проекта, и, как мне кажется, было бы неплохо поговорить об этом, чтобы быть одной волне. Лично у меня нет однозначного мнения на этот счет.

Главное не усложняйте, просто делайте проект простым.

Теги:
Хабы:
Всего голосов 14: ↑11 и ↓3+10
Комментарии24

Публикации

Информация

Сайт
tech.kontur.ru
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Варя Домрачева