Олег Марков
Горизонтальные слайсы horizontal-slices - практическое руководство по организации кода
Введение
Горизонтальные слайсы (horizontal-slices) — это способ организации кода, при котором вы группируете файлы по функциональным возможностям (фичам), а не по техническим слоям (контроллеры, сервисы, репозитории и т.п.).
Вместо структуры вида:
- controllers
- services
- repositories
- dto
- models
вы получаете структуру:
- users
- orders
- products
В каждой из таких папок лежит все, что нужно для работы конкретной части системы: обработчики запросов, бизнес‑логика, модели, data access, валидация, команды, запросы и т.д.
Подход горизонтальных слайсов часто обсуждают в контексте:
- Clean Architecture
- Vertical / Feature-Sliced Architecture
- CQRS и MediatR (в .NET)
- Frontend архитектур (например, Feature Sliced Design в React)
Задача статьи — показать, как на практике организовать код по горизонтальным слайсам, какие плюсы и минусы у этого подхода, какие варианты структуры папок существуют и как постепенно мигрировать со “слоёной” архитектуры.
Что такое горизонтальные слайсы
Идея подхода
Смотрите, идея очень простая: вы делите систему не по слоям, а по “кусочкам функциональности”, которые несут ценность — слайсам. Каждый слайс:
- реализует законченную фичу или поддомен
- содержит весь необходимый код для этой фичи
- минимально зависит от других слайсов
- общается с окружающим миром только через понятные контракты (интерфейсы, DTO, события)
Получается, что если вы хотите изменить поведение какой-то фичи, вам не нужно прыгать по пяти папкам. Достаточно зайти в одну — и почти все, что вам нужно, находится там.
Горизонтальные против вертикальных и слоёных архитектур
Чтобы не запутаться в терминах, давайте кратко сравним.
Слоёная архитектура (layered)
Типичная классика:
- UI / Controllers
- Application / Services
- Domain / Models
- Infrastructure / Repositories
Здесь вы группируете код по технической роли. Это удобно на старте, но со временем:
- файлы растут и смешиваются
- появляется «god‑service», который делает всё
- изменения в одной фиче требуют правок во многих местах
Вертикальные / фиче‑модули
Здесь вы группируете код по фичам:
- Users
- Orders
- Billing
Внутри папки фичи висят контроллеры, сервисы, DTO и т.п. Это уже гораздо ближе к горизонтальным слайсам.
Горизонтальные слайсы
Горизонтальный слайс — это фактически “вертикальный модуль”, но с более строгим подходом:
- каждый слайс — почти автономный модуль
- четко определены входы и выходы
- минимум кросс‑зависимостей
- упор на бизнес‑операции (Use Cases), а не на общие сервисы
Другими словами, вы не просто складываете файлы рядом, а проектируете каждый слайс как самостоятельную единицу поведения.
Когда горизонтальные слайсы особенно полезны
Типы проектов, для которых подход хорошо работает
Горизонтальные слайсы особенно полезны, если:
- у вас средний или крупный продукт с множеством бизнес‑фич
- над проектом работает несколько команд
- продукт развивается итеративно, с постоянными изменениями
- есть риск, что “общие” слои превратятся в свалки кода
Например:
- интернет‑магазин (users, catalog, cart, orders, payments)
- CRM / ERP система (clients, deals, invoices, reports)
- SaaS платформа (subscriptions, billing, profile, notifications)
Типы задач внутри одного слайса
Обычно в одном слайсе вы встречаете:
- обработчики HTTP / сообщений / команд
- бизнес‑операции (use cases)
- доменные сущности и value‑объекты
- адаптеры к базе данных или API
- DTO, мапперы, валидаторы
- unit‑ и интеграционные тесты, относящиеся к фиче
Сейчас покажу вам, как это может выглядеть в коде.
Базовая структура проекта с горизонтальными слайсами
Общий пример структуры
Давайте разберемся на примере backend‑проекта (я буду писать примеры на псевдо‑C# / TypeScript‑подобном синтаксисе — не так важно, какой у вас язык, важна идея структуры):
src/
users/
api/
get-user.handler.ts
create-user.handler.ts
application/
commands/
create-user.command.ts
queries/
get-user.query.ts
domain/
user.entity.ts
user-email.vo.ts
infrastructure/
user.repository.ts
user.mapper.ts
tests/
create-user.tests.ts
orders/
api/
create-order.handler.ts
get-order.handler.ts
application/
commands/
create-order.command.ts
queries/
get-order.query.ts
domain/
order.entity.ts
order-status.vo.ts
infrastructure/
order.repository.ts
tests/
order.tests.ts
shared/
infrastructure/
db/
db-context.ts
kernel/
result.ts
domain-event.ts
base-entity.ts
Смотрите, что здесь важно:
users/иorders/— это ваши горизонтальные слайсыshared/— всё, что действительно общее и не привязано к домену- внутри каждого слайса своя мини‑структура (api, application, domain, infrastructure)
Такой подход можно смело адаптировать под любой язык: Go, C#, Java, Node.js, PHP, Python.
Содержимое одного слайса по шагам
Теперь давайте пройдемся по типичному слайсу “Users” и посмотрим, что внутри.
API слой слайса
В users/api/ расположены входные точки — например, HTTP‑обработчики.
// users/api/create-user.handler.ts
// Здесь мы принимаем HTTP запрос и делегируем бизнес-логику в application слой
export async function createUserHandler(req, res) {
// Извлекаем данные тела запроса
const { email, name } = req.body;
// Создаем команду - объект, описывающий операцию
const command = new CreateUserCommand(email, name);
// Отправляем команду в соответствующий обработчик
const result = await createUserCommandHandler.handle(command);
if (!result.isSuccess) {
// Возвращаем ошибку клиенту
return res.status(400).json({ error: result.error });
}
// Возвращаем созданного пользователя
return res.status(201).json(result.value);
}
Основная идея:
- API слой ничего “умного” не делает
- он принимает запрос, превращает его в команду/запрос
- дальше передает в application слой и формирует ответ
Application слой (use cases)
В users/application/commands/ вы описываете бизнес‑операции (use cases).
// users/application/commands/create-user.command.ts
// Команда описывает входные данные use case
export class CreateUserCommand {
constructor(
public readonly email: string,
public readonly name: string
) {}
}
// users/application/commands/create-user.handler.ts
import { User } from "../../domain/user.entity";
import { UserEmail } from "../../domain/user-email.vo";
import { userRepository } from "../../infrastructure/user.repository";
// Обработчик команды инкапсулирует логику создания пользователя
export class CreateUserCommandHandler {
async handle(command: CreateUserCommand) {
// Валидируем email через value-object
const emailOrError = UserEmail.create(command.email);
if (emailOrError.isFailure) {
// Возвращаем неуспех с сообщением ошибки
return Result.failure(emailOrError.error);
}
// Создаем доменную сущность пользователя
const user = User.create({
email: emailOrError.value,
name: command.name
});
// Сохраняем пользователя в репозитории
await userRepository.save(user);
// Возвращаем успешный результат
return Result.success(user.toDto());
}
}
Обратите внимание:
- use case (команда + обработчик) живет внутри слайса
users - он использует только доменные объекты этого же слайса (
User,UserEmail) - репозиторий (
userRepository) тоже локален к слайсу
Domain слой
В users/domain/ находятся сущности и value‑объекты.
// users/domain/user-email.vo.ts
// Value-object для Email пользователя
export class UserEmail {
private constructor(private readonly value: string) {}
// Фабричный метод с валидацией
static create(email: string): Result<UserEmail> {
// Простейшая проверка формата
if (!email.includes("@")) {
return Result.failure("Некорректный email");
}
return Result.success(new UserEmail(email));
}
// Метод для получения строкового значения
getValue(): string {
return this.value;
}
}
// users/domain/user.entity.ts
// Доменная сущность пользователя
export class User extends BaseEntity {
private constructor(
id: string,
private email: UserEmail,
private name: string
) {
super(id);
}
// Создание нового пользователя
static create(props: { email: UserEmail; name: string }): User {
const id = generateId(); // Генерация идентификатора
return new User(id, props.email, props.name);
}
// Преобразование сущности в DTO
toDto() {
return {
id: this.id,
email: this.email.getValue(),
name: this.name
};
}
}
Здесь я размещаю пример, чтобы вам было проще увидеть, как domain слой не знает о базе данных, HTTP, логгировании и других инфраструктурных деталях.
Infrastructure слой
В users/infrastructure/ находятся реализации репозиториев и мапперов.
// users/infrastructure/user.repository.ts
import { db } from "../../shared/infrastructure/db/db-context";
import { User } from "../domain/user.entity";
import { UserEmail } from "../domain/user-email.vo";
// Репозиторий для работы с таблицей пользователей
class UserRepository {
async save(user: User): Promise<void> {
// Маппим доменную сущность в формат базы данных
const record = {
id: user.id,
email: user.getEmail().getValue(),
name: user.getName()
};
// Сохраняем данные в БД
await db.users.insert(record);
}
async findByEmail(email: UserEmail): Promise<User | null> {
const record = await db.users.findOne({
email: email.getValue()
});
if (!record) return null;
// Восстанавливаем доменную сущность из записи БД
const emailVo = UserEmail.create(record.email).value;
return new User(record.id, emailVo, record.name);
}
}
// Создаем и экспортируем единственный экземпляр репозитория
export const userRepository = new UserRepository();
Теперь вы видите, как слайс users содержит полный стек — от API до базы. Это и есть горизонтальный слайс.
Связи между слайсами
Полностью изолировать фичи обычно не получается — им нужно взаимодействовать. Важно делать это аккуратно.
Подход 1 — обращение к application слою другого слайса
Пример: слайс orders должен получить пользователя по id.
// orders/application/commands/create-order.handler.ts
import { usersFacade } from "../../users/application/users-facade";
// Обработчик команды создания заказа
export class CreateOrderCommandHandler {
async handle(command: CreateOrderCommand) {
// Получаем данные пользователя через фасад слайса users
const user = await usersFacade.getUserById(command.userId);
if (!user) {
// Если пользователя нет - бизнес-ошибка
return Result.failure("Пользователь не найден");
}
// Здесь создаем заказ и сохраняем его
// ...
}
}
Где usersFacade — публичный API слайса users.
// users/application/users-facade.ts
import { userRepository } from "../infrastructure/user.repository";
// Публичный фасад для других слайсов
class UsersFacade {
async getUserById(userId: string) {
const user = await userRepository.findById(userId);
if (!user) return null;
// Возвращаем только нужные данные
return {
id: user.id,
email: user.getEmail().getValue()
};
}
}
export const usersFacade = new UsersFacade();
Обратите внимание, как этот фрагмент кода решает задачу:
- другие слайсы не лазят в базу
users - они знают только про фасад, который возвращает безопасные DTO
- доменная модель
Userостается локальной
Подход 2 — асинхронные события
Вместо прямых вызовов вы можете использовать доменные или интеграционные события: один слайс публикует событие, другой на него подписывается.
Простейший пример доменного события:
// shared/kernel/domain-event.ts
// Базовый класс для доменных событий
export abstract class DomainEvent {
// Идентификатор события
public readonly id: string = generateId();
// Время создания события
public readonly occurredAt: Date = new Date();
}
// Событие - пользователь зарегистрирован
export class UserRegisteredEvent extends DomainEvent {
constructor(
public readonly userId: string,
public readonly email: string
) {
super();
}
}
Слайс users поднимает событие:
// users/domain/user.entity.ts
// Внутри метода создания пользователя
static create(props: { email: UserEmail; name: string }): User {
const id = generateId();
const user = new User(id, props.email, props.name);
// Добавляем событие о регистрации пользователя
user.addDomainEvent(
new UserRegisteredEvent(id, props.email.getValue())
);
return user;
}
Слайс notifications может подписаться и, например, отправить письмо.
Так вы еще сильнее разрываете связи между слайсами.
Как перейти со “слоёной” архитектуры к горизонтальным слайсам
Часто проект уже живет в классическом виде: controllers / services / repositories. Полностью переписать структуру за один раз опасно. Логичнее двигаться постепенно.
Шаг 1 — выделите фичи
Для начала вы описываете список ключевых фич (или bounded contexts):
- users
- orders
- products
- inventory
- billing
Фич может быть меньше или больше, важно, чтобы они были бизнес‑смысловыми, а не техническими.
Шаг 2 — создайте папки‑слайсы и начните выносить код
Покажу вам, как это реализовано на практике:
- Создаете папку
src/users - Находите весь код, который очевидно относится к пользователям
- Переносите его в
users, не меняя поведение (минимум правок) - Строите внутри привычную мини‑структуру: api, application, domain, infrastructure
- Настраиваете импорт путей (alias), чтобы старый код не поломался
Так вы постепенно “обжимаете” старые слои, перенося их содержимое в слайсы.
Шаг 3 — введите правила зависимостей
Чтобы структура не превратилась в хаос, стоит ввести правила (и желательно автоматизировать их проверку, например, с помощью линтеров):
- слайс может зависеть только от:
shared/- строго ограниченного списка других слайсов (белый список)
- запрещены циклические зависимости между слайсами
- domain слой не зависит от infrastructure
- api слой не ходит напрямую в репозитории
В JS/TS проектах можно использовать ESLint с плагином import/no-cycle и custom‑правилами, в .NET — анализаторы зависимостей, в Go — простые скрипты проверки импортов.
Плюсы и минусы горизонтальных слайсов
Плюсы
Локальность изменений
Изменения в одной фиче чаще всего затрагивают только один слайс. Меньше шансов что‑то сломать в другой части системы.Удобство для командной работы
Команды могут владеть конкретными слайсами. Меньше конфликтов в общих сервисах, проще разграничивать ответственность.Проще ориентироваться в коде
Если вам сказали “Логика регистрации пользователей”, вы сразу идете вusers/. Там вы видите API, домен, репозитории — всё рядом.Лучше масштабируется по функциональности
Когда появляется новая фича, вы просто создаете новый слайс. Не нужно решать, в какой существующий “общий” сервис засунуть логику.Упрощение рефакторинга
Каждый слайс можно постепенно переписывать на новые подходы или технологии, почти не задевая остальные.
Минусы и сложности
Сложность перехода со старой архитектуры
Переход по живому проекту требует аккуратного плана и времени. Быстрая миграция “за один спринт” редко бывает реальной.Риск появления дублирования
В начале кажется, что код начал дублироваться между слайсами. Часть этого дублирования полезна (каждый слайс автономен), но часть можно вынести вshared/. Важно не спешить с выносом в “общие” модули.Необходимость дисциплины по зависимостям
Без правил разработчики начнут “протягивать” импорты как им удобно. В итоге слайсы превратятся в обычные папки без особого смысла.Иногда сложнее сделать cross‑feature сценарии
Механики, которые затрагивают несколько фич сразу (например, массовый отчёт по пользователям и заказам), требуют продуманного взаимодействия слайсов и контрактов между ними.
Варианты детализации структуры внутри слайса
Подход к внутренней структуре можно адаптировать под ваши нужды. Давайте посмотрим несколько вариантов.
Вариант 1 — Минимальный (api, domain, infra)
Подходит для небольших проектов:
users/
api/
domain/
infra/
Внутри domain/ могут быть сразу и сущности, и use cases. Используйте такой подход, если домен простой и нет смысла делить слой application.
Вариант 2 — CQRS‑ориентированный (commands, queries)
Часто встречается в .NET / Node.js проектах с MediatR‑подобным подходом:
users/
api/
application/
commands/
queries/
domain/
infrastructure/
Похожий на пример, который я показывал выше. Удобен, когда у вас много запросов и команд, и вы хотите держать их раздельно.
Вариант 3 — Подслайсы по сценарию
Когда фича большая, можно внутри нее сделать ещё более мелкие “под‑слайсы”:
users/
registration/
api/
application/
domain/
infrastructure/
profile/
api/
application/
domain/
infrastructure/
shared/
domain/
user-id.vo.ts
Так вы сохраняете идею горизонтальных слайсов, но уже на уровне более детальных сценариев: регистрация, профиль, безопасность.
Тестирование в контексте горизонтальных слайсов
Где хранить тесты
Самый понятный вариант — класть тесты рядом со слайсом:
users/
tests/
create-user.tests.ts
get-user.tests.ts
Или даже ближе — рядом с конкретным обработчиком:
users/
application/
commands/
create-user.command.ts
create-user.handler.ts
create-user.handler.test.ts
Смотрите, так вы сразу видите, где находятся тесты для конкретного поведения, и не бегаете по общим tests/ папкам.
Что тестировать
Use cases (application слой)
Проверяете, что команда с валидными данными создаёт сущность, вызывает репозиторий и возвращает нужный результат.Domain слой
Тестируете инварианты сущностей и value‑объектов: какая логика выполняется при изменении состояния.API слой
Можно делать интеграционные тесты: поднять app, отправить HTTP запрос, проверить ответ. Важно — вы тестируете в контексте одного слайса.
Типичные ошибки при внедрении горизонтальных слайсов
Ошибка 1 — слишком быстрый вынос всего в shared
Разработчики видят похожие куски кода и сразу делают “общий модуль”. Через пару месяцев в shared лежит половина проекта, а слайсы становятся только “тонкими обертками”.
Лучше выносить общие модули:
- только когда повторение кода действительно мешает
- только после того, как становится понятно, что повторение — не особенности домена, а техническая деталь
- по принципу: минимум общего, максимум независимых слайсов
Ошибка 2 — кросс‑зависимости домена
Когда сущности одного слайса начинают напрямую знать о сущностях другого, возникает жёсткая сцепка.
Лучше:
- использовать идентификаторы (userId, orderId), а не ссылки на чужие сущности
- общаться через фасады слайсов или события
- передавать только DTO, а не “чужие” доменные модели
Ошибка 3 — сохранение старого “god‑service” внутри одного слайса
Иногда слой “services” просто переезжает внутрь слайса users, оставаясь таким же гигантским.
Решение:
- дробить операции на отдельные use cases
- не бояться иметь много мелких обработчиков команд и запросов
- делить логику по смыслу, а не по технике
Как начать использовать horizontal-slices на новом проекте
Минимальный чек‑лист
Давайте посмотрим, что стоит сделать в самом начале:
- Описать список ключевых слайсов (фич или контекстов)
- Создать для каждого отдельную папку
- Внутри задать базовую структуру: api, application, domain, infrastructure
- Определить
shared/и сразу решить, что туда попадает (инфраструктурные детали, утилиты, kernel) - Ввести правила зависимостей:
- слайс → shared
- слайс → ограниченный набор других слайсов через фасады или события
- Настроить линтеры / анализаторы зависимостей, если язык позволяет
- Оговаривать архитектурные решения по мере роста приложения, а не вносить всё в shared “про запас”
Пример минимального шаблона
Можно сделать себе шаблон (скелет), который будете копировать при создании нового слайса:
template-feature/
api/
index.ts
application/
commands/
queries/
domain/
infrastructure/
tests/
Потом просто копируете папку, переименовываете, и у вас сразу готов каркас для новой фичи.
Заключение
Горизонтальные слайсы — это способ организовать проект вокруг бизнес‑функциональности, а не вокруг технических слоёв. В результате:
- код, относящийся к одной фиче, сосредоточен в одном месте
- каждая фича выглядит как самостоятельный модуль с понятными границами
- проще вносить изменения, поддерживать и тестировать систему
- командам легче делить зоны ответственности
Подход не отменяет принципов Clean Architecture, DDD или CQRS, а скорее помогает их приземлить в реальную структуру папок и модулей. Для успешного внедрения важно:
- начать с выделения понятных бизнес‑слайсов
- постепенно переносить код, не ломая работу системы
- соблюдать дисциплину зависимостей
- не злоупотреблять “общими” модулями
Если все это делать аккуратно, проект становится более предсказуемым и понятным — как для текущей команды, так и для тех, кто присоединится позже.
Частозадаваемые технические вопросы по теме и ответы
Как ограничить зависимости между слайсами технически, а не только договорённостями?
Используйте инструменты анализа импортов:
- в JS/TS — ESLint с правилами типа
import/no-restricted-paths, где вы указываете, какие папки можно импортировать из какой - в .NET — Roslyn analyzers или ArchUnitNET, где задаёте правила зависимостей между namespaces
- в Java — ArchUnit
Смысл один: явно описать, чтоordersне может импортировать что угодно изusers, а только фасад или публичный контракт.
Как быть с общими DTO, которые используются в нескольких слайсах?
Разделите DTO на:
- внешние (для API) — можно держать ближе к API слою и фиче
- внутренние (для обмена между слайсами) — лучше вынести в
shared/contracts/
Каждый слайс маппит свои доменные модели в общие контракты. Не используйте доменные модели одного слайса как DTO в другом.
Как правильно версионировать слайсы при больших изменениях в фиче?
Используйте версионирование на уровне:
- API —
v1,v2маршрутов или отдельных обработчиков в слайсе - контрактов между слайсами — создайте
users/contracts/v1,users/contracts/v2и постепенно переводите потребителей
Внутренние изменения домена не обязательно версионировать, если они не ломают публичные контракты.
Как внедрять cross-cutting вещи (логгирование, метрики, авторизацию) в контексте horizontal-slices?
Держите инфраструктуру в shared/ (логгер, метрики, middleware) и используйте:
- middleware / interceptors для запросов и команд
- базовые классы и декораторы для типовых аспектов
Важный момент — не смешивать бизнес‑логику с логированием: пусть обработчики use case получают уже настроенные сервисы изshared/, а не создают их сами.
Как организовать миграции БД, если таблицы принадлежат разным слайсам?
Есть два подхода:
- Общие миграции в
shared/infrastructure/migrations, но с явным указанием слайса в имени файла и комментариях - Локальные миграции в каждом слайсе (
users/infrastructure/migrations), а на уровне инструмента миграций вы собираете все пути миграций вместе
Второй вариант лучше отражает идею: каждый слайс отвечает за свою схему данных, но runtime‑инструмент применяет миграции всех слайсов.