Горизонтальные слайсы horizontal-slices - практическое руководство по организации кода

05 января 2026
Автор

Олег Марков

Введение

Горизонтальные слайсы (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 — создайте папки‑слайсы и начните выносить код

Покажу вам, как это реализовано на практике:

  1. Создаете папку src/users
  2. Находите весь код, который очевидно относится к пользователям
  3. Переносите его в users, не меняя поведение (минимум правок)
  4. Строите внутри привычную мини‑структуру: api, application, domain, infrastructure
  5. Настраиваете импорт путей (alias), чтобы старый код не поломался

Так вы постепенно “обжимаете” старые слои, перенося их содержимое в слайсы.

Шаг 3 — введите правила зависимостей

Чтобы структура не превратилась в хаос, стоит ввести правила (и желательно автоматизировать их проверку, например, с помощью линтеров):

  • слайс может зависеть только от:
    • shared/
    • строго ограниченного списка других слайсов (белый список)
  • запрещены циклические зависимости между слайсами
  • domain слой не зависит от infrastructure
  • api слой не ходит напрямую в репозитории

В JS/TS проектах можно использовать ESLint с плагином import/no-cycle и custom‑правилами, в .NET — анализаторы зависимостей, в Go — простые скрипты проверки импортов.


Плюсы и минусы горизонтальных слайсов

Плюсы

  1. Локальность изменений
    Изменения в одной фиче чаще всего затрагивают только один слайс. Меньше шансов что‑то сломать в другой части системы.

  2. Удобство для командной работы
    Команды могут владеть конкретными слайсами. Меньше конфликтов в общих сервисах, проще разграничивать ответственность.

  3. Проще ориентироваться в коде
    Если вам сказали “Логика регистрации пользователей”, вы сразу идете в users/. Там вы видите API, домен, репозитории — всё рядом.

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

  5. Упрощение рефакторинга
    Каждый слайс можно постепенно переписывать на новые подходы или технологии, почти не задевая остальные.

Минусы и сложности

  1. Сложность перехода со старой архитектуры
    Переход по живому проекту требует аккуратного плана и времени. Быстрая миграция “за один спринт” редко бывает реальной.

  2. Риск появления дублирования
    В начале кажется, что код начал дублироваться между слайсами. Часть этого дублирования полезна (каждый слайс автономен), но часть можно вынести в shared/. Важно не спешить с выносом в “общие” модули.

  3. Необходимость дисциплины по зависимостям
    Без правил разработчики начнут “протягивать” импорты как им удобно. В итоге слайсы превратятся в обычные папки без особого смысла.

  4. Иногда сложнее сделать 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/ папкам.

Что тестировать

  1. Use cases (application слой)
    Проверяете, что команда с валидными данными создаёт сущность, вызывает репозиторий и возвращает нужный результат.

  2. Domain слой
    Тестируете инварианты сущностей и value‑объектов: какая логика выполняется при изменении состояния.

  3. API слой
    Можно делать интеграционные тесты: поднять app, отправить HTTP запрос, проверить ответ. Важно — вы тестируете в контексте одного слайса.


Типичные ошибки при внедрении горизонтальных слайсов

Ошибка 1 — слишком быстрый вынос всего в shared

Разработчики видят похожие куски кода и сразу делают “общий модуль”. Через пару месяцев в shared лежит половина проекта, а слайсы становятся только “тонкими обертками”.

Лучше выносить общие модули:

  • только когда повторение кода действительно мешает
  • только после того, как становится понятно, что повторение — не особенности домена, а техническая деталь
  • по принципу: минимум общего, максимум независимых слайсов

Ошибка 2 — кросс‑зависимости домена

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

Лучше:

  • использовать идентификаторы (userId, orderId), а не ссылки на чужие сущности
  • общаться через фасады слайсов или события
  • передавать только DTO, а не “чужие” доменные модели

Ошибка 3 — сохранение старого “god‑service” внутри одного слайса

Иногда слой “services” просто переезжает внутрь слайса users, оставаясь таким же гигантским.

Решение:

  • дробить операции на отдельные use cases
  • не бояться иметь много мелких обработчиков команд и запросов
  • делить логику по смыслу, а не по технике

Как начать использовать horizontal-slices на новом проекте

Минимальный чек‑лист

Давайте посмотрим, что стоит сделать в самом начале:

  1. Описать список ключевых слайсов (фич или контекстов)
  2. Создать для каждого отдельную папку
  3. Внутри задать базовую структуру: api, application, domain, infrastructure
  4. Определить shared/ и сразу решить, что туда попадает (инфраструктурные детали, утилиты, kernel)
  5. Ввести правила зависимостей:
    • слайс → shared
    • слайс → ограниченный набор других слайсов через фасады или события
  6. Настроить линтеры / анализаторы зависимостей, если язык позволяет
  7. Оговаривать архитектурные решения по мере роста приложения, а не вносить всё в 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/, а не создают их сами.

Как организовать миграции БД, если таблицы принадлежат разным слайсам?

Есть два подхода:

  1. Общие миграции в shared/infrastructure/migrations, но с явным указанием слайса в имени файла и комментариях
  2. Локальные миграции в каждом слайсе (users/infrastructure/migrations), а на уровне инструмента миграций вы собираете все пути миграций вместе
    Второй вариант лучше отражает идею: каждый слайс отвечает за свою схему данных, но runtime‑инструмент применяет миграции всех слайсов.
Стрелочка влевоСлайс либы lib-slice - удобные утилиты для работы со срезами в GoСлайс API в Go - структура среза операции и подводные камниСтрелочка вправо

Все гайды по Fsd

Открыть базу знаний

Отправить комментарий