Простой вопрос: делая задачу, касающуюся 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, но общие концепции можно почерпнуть.

Комментарии (9)


  1. Kroen89
    08.06.2025 18:03

    Тоже недавно открыл для себя VSA и пока что вполне ей доволен.

    Посмотрим как она поведёт себя при усложнении проекта


  1. ritorichesky_echpochmak
    08.06.2025 18:03

    Видимо мне наглядности не хватает, но пока выглядит как "я не смог пилить архитектуру, поэтому буду по старинке складывать всё в кучу". Для тупней типа меня - отличненько


    1. Espleth Автор
      08.06.2025 18:03

      =)
      Все мы иногда тупни. Найти себе сплошных senior-ninja-10+yoe-developer в команду, чтобы поддерживать сложную архитектуру, и не испортить все в первый кранч/отпуск тимлида (и это еще если тимлид сам понимает, что делает) - это, конечно, здорово, но сложно и дорого.

      Можно найти довольно много книг/статей, и вот прям шаг за шагом построить себе красивую архитектуру следуя всем заветам, и для этого не потребуется быть каким-то одаренным разработчиком. Однако, точно ли оно стоило потраченного времени? Точно ли бизнес выиграет от этого оверхэда? Точно ли коллега прочел ту же книгу и на одной волне с тобой, или просто делает вид?
      На разных проектах свои правильные ответы на эти вопросы, но у меня есть ощущение, что к сожалению их обычно не задают себе.


  1. S1908
    08.06.2025 18:03

    Все становится сложным когда сущностей в бд 100+. Вы предлагаете делить одномерно по VSA или HSA. Я уже давно применяю подход двумерного деления: VSA+HSA. Если интересно могу описать.


    1. Espleth Автор
      08.06.2025 18:03

      Я там местами описал, в параграфах про DDD и границы подхода: далеко не весь код проекта получится (и стоит) поделить прям по слайсам: горизонтальные слои все равно останутся, и для меня суть VSA скорее в том, чтобы избавиться от горизонтальных слоев (и не размазывать код по всему проекту) там, где это возможно.
      Так что, возможно, корректнее это было бы назвать VSA+HSA. Но если ваше понимание этого отличается, то интересно было бы услышать.


  1. S1908
    08.06.2025 18:03

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


  1. jakobz
    08.06.2025 18:03

    Вообще, я тут на эту тему размышлял в таком стиле: если у нас в моде теперь микросервисы, а монолиты типа плохо - почему мы внутри одного монолита даже не пытались все эти годы делить код на части?

    Можно же придумать и как ограничить куски БД, которым управляет каждый слайс. И сделать взаимодействие «слайсов» так, чтобы это были асинхронные вызовы с сериализуемыми параметрами. Чтобы чуть что - деплоить этот слайс отдельно, или выделить в отдельную репу.

    Но там куча вопросов, начиная с того, как разложить это по проектам. Я пока ничего лучше эдакой 2х мерной архитектуры не придумал: у меня разбито на проекты как обычно - common (dto, интерфейсы), services (реализация), web (контроллеры и прочее про веб). Но в каждом проекте - все лежит по одинаково названным папочкам, типа Users, Products, и т д. И этим слайсам разрешено общаться только через их публичные интерфейсы в common. Так себе, конечно, но хоть какой-то порядочек.

    Особенно этот вопрос заиграл с LLM-ками: им надо четкие правила как что класть, и где что искать, ограничить им контекст нужно.


    1. Espleth Автор
      08.06.2025 18:03

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

      Но с LLM-ками да. По моему опыту, они могут подсказывать куда чаще и качественнее с VSA структурой (да и просто если писать код не забывая о KISS), чем с огородом из слоев и абстракций, даже при одинаковом функционале проекта.


  1. Heggi
    08.06.2025 18:03

    Я в своих проектах заменил MediatR на аналог с кодогенерацией https://www.nuget.org/packages/Mediator.SourceGenerator