Как стать автором
Обновить

Vertical Slice Architecture на примере C# — простая и удобная архитектура для небольших (и не только) пректов

Уровень сложностиСредний
Время на прочтение6 мин
Количество просмотров3.7K

Простой вопрос: делая задачу, касающуюся API - вы чаще работаете с одним эндпоинтом, или пишете, условные, репозитории, которые используются сразу в нескольких эндпоинтах? Скорее всего, первое, тогда почему мы разбиваем проект по слоям, а не по фичам (эндпоинтам)?

Это видно в часто используемых нынче архитектурных подходах: Layered, Clean Architecture, Onion, и так далее. Не буду выделять что-то конкретное и объясню общую разницу в подходах:
Vertical Slice Architecture (VSA) строится вокруг каждого отдельного feature-слайса (эндпоинта, как самый простой пример), а не вокруг слоев.

То есть, если код относится к конкретному эндпоинту, мы не размазываем его по всему проекту в папках Commands/Services/Repositories/DTOs и т.п., а кладем в одно место, там где и будет находиться эндпоинт. На картинке это выглядит так:

Из статьи https://www.jimmybogard.com/vertical-slice-architecture/
Из статьи https://www.jimmybogard.com/vertical-slice-architecture/

Главный принцип - уменьшаем связанность между слайсами (фичами), увеличиваем связанность внутри слайса

Зачем?

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

От огромных сервисов то мы ушли, только вот теперь скроллим не тысячи строк когда, а сотни папок.

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

Данный подход довольно новый (опустим про хорошо забытое старое - я уверен многие сами его давно используют неосознанно), даже на хабре сложно найти авторскую статью. В 2018 году, например, о нем написал Jimmy Bogard, автор всем нам знакомого MediatR и AutoMapper, а теперь уже и тренинги по VSA проводит.
Как знать, может через несколько лет и очередной eShop попробует данную архитектуру.

Пример организации проекта

Поскольку архитектура довольно новая, устоявшихся названий и понятий еще нет, поэтому код ниже не стоит воспринимать как единственно-верный вариант
Также буду использовать Minimal API (но никто не мешает пользоваться контроллерами)

Немного о Minimal API

Кто еще не успел познакомиться с этим нововведением dotnet - не помешает. Это не просто желание "мы тоже хотим писать на c# API в 4 строчки кода", а действительно хороший инструмент, который призван заменить устаревшие контроллеры. На хабре мало инфы, если данная статья зайдет, напишу о том, как использовать Minimal API в реальном проекте с кучей инфраструктуры, а не просто Hello World

Структура будет выглядеть следующим образом:

Program.cs
Endpoints (или, как часто называют, Features)
|   User
   |   GetUserInfo.cs
      |   UserInfoResponse.cs

GetUserInfo.cs:

public class GetUserInfo : IEndpoint
{
    public void Register(IEndpointRouteBuilder endpointsBuilder)
    {
        // Регистрация эндпоинта. В случае контроллера это был бы стандартный метод с атрибутом [HttpGet("user/info")].
        endpointsBuilder.MapGet("/user/info", HandleAsync)
            .RequireAuthorization()
            .WithDescription("Get user basic info")
            .WithTags("User");
    }
    
    // Параметры этого метода почти аналогичны параметрам метода контроллера, DI зарезолвит все необходимые зависимости.
    public static Task<UserInfoResponse> HandleAsync(ExampleDbContext db, UserContext userContext, CancellationToken ct)
    {
        return db.Users.Where(x => x.Id == userContext.UserId)
            .Select(x => new UserInfoResponse
            {
                UserId = x.Id,
                Username = x.Username,
            })
            .SingleAsync(ct);
    }
}

Где в HandleAsync() - вся необходимая логика запроса. Этот метод сделан публичным для возможности тестирования, об этом чуть ниже.
Такая наглядность позволит подпускать к проекту не только сеньоров с солидным архитектурным бэкграундом, но и разработчиков с опытом поменьше. Да и для вас самих GitHub Copilot, или чем вы пользуетесь, сможет банально лучше анализировать весь нужный код т.к. будет иметь более полный контекст.

IEndpoint - просто интерфейс для удобства регистрации эндпоинта Minimal API с единственным методом Register(): в отличии от контроллеров, магической рефлексии из коробки нет. Подробнее, как я говорил, стоит разобрать в отдельной статье.

Можно заметить, что в описанной мной структуре UserInfoResponse.cs находится внутри GetUserInfo.cs: если что, да, так можно делать, просто перетащив файл в другой в IDE. В .csproj для этого появится соответствующая запись с DependentUpon. Парой абзацев ниже скриншот для примера.
Но если не нравится - можно создать папку GetUserInfo и сложить в нее все файлы, относящиеся к данному эндпоинту (и сам эндпоинт)

MediatR

Кто не увидел MediatR - не беспокойтесь - ничто не мешает использовать его с паттерном CQRS в данной схеме. Все необходимые файлы просто лягут под EndpointName.cs, а не размажутся тонким слоем по проекту.

выглядит так
выглядит так

Ниже я оставлю ссылки на репозитории, делают именно так. Или же, как можно заметить в этих репозиториях, кто-то вообще создает эти классы прямо внутри файла EndpointName.cs. А почему бы и нет? Несколько классов в одном файле - это, конечно, вполне себе антиппатерн. Но как и всегда в таких случаях, это не значит что это автоматически "плохо". Это плохо только если не понимаешь, почему обычно так не делают.

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

Domain-Driven Design и другие архитектуры

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

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

Тестирование

Тут все просто - вызываем наш HandleAsync, передаем нужные параметры и моки сервисов.
Может, не так красиво, как с использованием DI и MediatR, но зато при изменении эндпоинта больше вероятность отловить ошибку в тесте на этапе компиляции.
Если хочется красиво и с вызовами API - можно так.

Использование за пределами API

Конечно, большинство проектов не состоит из одних только эндпоинтов: у нас есть очереди, job'ы и т.п. Но ничто не мешает использовать там такой же подход. Более того: я думаю многие заметят, что они сами, осознанно или нет, уже так делают. А если нет - время задуматься.

Ну и в целом, такой подход легко выходит не только за пределы не только бэкенда (Feature-Sliced Design - пример из мира фронтэнда), но и кода вообще: мы же, например, не разбиваем команды в проекте по принципу "эта команда пишет контроллеры, а эта - хэндлеры".

Микросервисы и монолиты

Вполне логично, что VSA может работать и так и так. И такая архитектура проще разбивается на микросервисы: ведь мы выделяем их не по слоям "это у нас микросервис Domain", а по фичам. Может, с таким подходом "у нас монолит, но мы переходим на микросервисы" в каждой второй компании было бы чуть меньше.

Границы тоже есть

На заглавной картинке видно, как слайс проходит прямо по базе данных. Кто-то может сделать вывод, что Entity/модели, представляющие таблицы в БД, тоже нужно ограничивать областью слайса.
Если у вас на проекте подход Code-first (или просто полная репрезентация БД в коде), не соглашусь: это все еще должен быть отдельный слой. Если же вы пишете DTO для данных из базы под конкретный эндпоинт - этот DTO прекрасно ляжет в слайс.
Аналогично, если используете паттерн репозитория, тут, конечно, зависит от вашей реализации: если метод репозитория используется в одном-единственном эндпоинте/группе эндпоинтов, и его можно выделить не в ущерб тестированию, то, вероятно, так и стоит сделать: общий принцип - не держать код конкретной фичи вне ее пределов.

Похожие подходы

На хабре уже была статья-перевод, вдохновленная VSA, только с группированием больше по целым модулям, а не конкретным эндпоинтам. Для наглядности, приведу пример структуры оттуда:

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

Слайс тут - не отдельный эндпоинт, а группа. Принципиально подход не меняется. Такой подход предлагал небезызвестный Роберт Мартин и назвал это Screaming Architecture. Главная идея тут - что архитектура "кричит": по структуре проекта мы можем понять не просто, какой архитектурный подход использовался, а и то, какой функционал выполняет данный проект.

На практике, как и везде, конечно, не все гладко

Что если у двух эндпоинтов какой-то общий между собой код? Использовать сервисы/хэлперы никто не запрещает. Поместить их можно рядом с эндпоинтами: зачем выносить уникальный для них код куда-то за их пределы. Ну или, вот статья на эту тему для вдохновения.
Или, если эндпоинты почти идентичны (например, из-за версионирования), может совсем объединить их в один файл.
Однако, не забываем, что принцип DRY, конечно, важен, но 3 строчки одинакового кода - еще не повод пересмотреть архитектуру проекта - все равно она не будет идеальной. Даже если кажется что будет.

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

Главное, что хочется отметить: при проектировании нужно думать не о том, как написали вон в той авторитетной статье/книге, а головой.

Напоследок, поскольку я привел только весьма упрощенный пример, а не инструкцию к действию, несколько проектов VSA на GitHub:
ContosoUniversity от Jimmy Bogard. Давненько не обновлялся, но как реф подойдет.
Еще пример VSA со своей статьей
Food Delivery Microservices - work in progress, но общие концепции можно почерпнуть.

Теги:
Хабы:
+7
Комментарии19

Публикации

Работа

Ближайшие события