Контроллер, сервис и репозиторий: где проходит граница
Разделение на контроллер, сервис и репозиторий помогает не превратить обработчик HTTP-запроса в комбайн из SQL, бизнес-правил и сериализации. Разбираем границы слоёв, цену лишней абстракции и признаки плохой архитектуры.

Когда обработчик заказа одновременно читает JSON, проверяет остатки, считает скидку, пишет четыре таблицы и формирует HTTP-ответ, любое изменение начинает цеплять весь код. Разделение на контроллер, сервис и репозиторий переносит ответственность в отдельные слои: контроллер работает с транспортом, сервис принимает бизнес-решения, репозиторий общается с хранилищем. Ниже — практическая схема для серверных приложений без привязки к конкретному языку или фреймворку, с критериями, когда она действительно окупается.
Что должен делать каждый слой
Слои — это не обязательные папки с одинаковыми названиями. Это границы ответственности. Если правило можно изменить без переписывания HTTP-обработчика или SQL-запроса, граница проведена удачно.
Контроллер: транспорт, а не бизнес-логика
Контроллер получает входные данные из HTTP, команды очереди или другого внешнего протокола. Он проверяет форму запроса, преобразует данные во внутреннюю команду, вызывает сервис и переводит результат в ответ протокола.
- читает параметры URL, заголовки и тело запроса;
- проверяет обязательные поля и базовые ограничения формата;
- передаёт в сервис идентификатор пользователя и входную команду;
- выбирает код ответа и формат ошибки;
- не решает, можно ли применить скидку или списать товар.
Проверка формата и проверка бизнес-условия — разные задачи. Строка даты должна иметь допустимый формат на границе приложения. Но вопрос «можно ли отменить заказ после передачи в доставку» относится к бизнес-правилам и должен жить не в контроллере.
Сервис: сценарий и бизнес-решение
Сервис описывает операцию, которую понимает бизнес: создать заказ, отменить подписку, назначить роль, вернуть платёж. Здесь находятся проверки, порядок действий и правила перехода между состояниями.
Для создания заказа сервис может:
- загрузить актуальные позиции и цены через репозитории;
- проверить доступность товара и права покупателя;
- рассчитать итоговую сумму по правилам домена;
- создать заказ и зарезервировать остатки;
- вернуть результат контроллеру или сообщить об отказе.
Сервис не обязан знать, что запрос пришёл по HTTP. Тот же сценарий должен быть вызываем из фоновой задачи, консольной команды или обработчика сообщения, если это нужно продукту.
Репозиторий: доступ к данным
Репозиторий скрывает детали хранения: SQL, ORM, документную базу или внешний API, который приложение использует как хранилище. Его задача — найти, сохранить или изменить данные, а не решить, имеет ли пользователь право на операцию.
Хороший репозиторий говорит на языке предметной области: «найти активную корзину пользователя» понятнее, чем «выполнить запрос с таким-то условием». Но он не должен превращаться в копию сервиса с десятком бизнес-проверок. Если репозиторий начинает решать, разрешена ли отмена заказа, правило оказалось не на своём месте.
Контроллер отвечает на вопрос «как пришёл запрос», сервис — «что нужно сделать», репозиторий — «где лежат данные и как их получить».
Слой — логическая граница ответственности, а не обязательно отдельный процесс или модуль. В небольшом приложении несколько слоёв могут находиться в одном пакете, если зависимости между ними остаются направленными и понятными.
Как проходит один сценарий
Возьмём отмену заказа. Контроллер извлекает идентификатор заказа и пользователя, проверяет, что идентификатор имеет допустимый формат, и вызывает сервис отмены. Сервис получает заказ через репозиторий, проверяет его состояние, принадлежность пользователю и возможность отмены, затем сохраняет новое состояние.
Репозиторий при этом не должен знать о HTTP-коде 409 или тексте сообщения для клиента. Он возвращает найденный объект, отсутствие объекта или ошибку доступа к хранилищу. Сервис превращает эти результаты в понятные приложению исходы, а контроллер — в ответы конкретного протокола.
Если отмена меняет заказ, резерв товара и запись о возврате денег, граница транзакции обычно должна охватывать весь сценарий, а не отдельный вызов каждого репозитория. Конкретный механизм зависит от базы и фреймворка. Важно не название аннотации, а атомарность: нельзя сохранить отменённый заказ и потерять возврат резерва из-за частичного сбоя.
Где обычно ошибаются
Толстый контроллер
Признаки: обработчик длиннее сценария, в нём есть SQL или вызовы ORM, ветвления по статусам заказа, расчёт денег и несколько операций записи. Такой код трудно вызвать не через HTTP и трудно покрыть тестами без поднятия всего веб-стека.
Исправление — не механически перенести каждую строку в отдельный класс. Сначала выделите один бизнес-сценарий в сервис, затем оставьте контроллеру преобразование входа и выхода. Если сервис всё ещё знает о заголовках, кодах ответа и типах HTTP-исключений, граница проведена не до конца.
Сервис-обёртка без смысла
Иногда сервис содержит один вызов репозитория и не добавляет ни правила, ни координации. Это не ошибка само по себе для простого CRUD, но дополнительный слой имеет цену: больше файлов, интерфейсов и тестовых дублей.
Не создавайте сервис только потому, что так принято в шаблоне проекта. Он оправдан, когда сценарий имеет бизнес-правило, несколько зависимостей, транзакционную границу или должен запускаться из разных входных каналов.
Репозиторий с бизнес-логикой
Антипаттерн выглядит так: метод репозитория сам проверяет роль пользователя, считает скидку и в зависимости от статуса решает, какие таблицы обновлять. В результате правила спрятаны в слое данных, а другой способ запуска операции начинает обходить их.
В репозитории допустимы условия, необходимые для эффективного запроса: фильтрация по владельцу, статусу или периоду. Но решение «можно ли пользователю выполнить операцию» должно оставаться в сервисе или доменном компоненте.
Слои, которые ходят друг к другу хаотично
Базовое направление зависимостей выглядит так: контроллер вызывает сервис, сервис вызывает репозиторий. Обратные вызовы ломают модель. Репозиторий не должен импортировать контроллер, а сервис не должен формировать HTML или HTTP-ответы.
| Подход | Когда уместен | Цена | Главный риск |
|---|---|---|---|
| Обработчик напрямую вызывает хранилище | Маленький CRUD с несколькими операциями | Минимум кода и файлов | Логика быстро смешивается с транспортом |
| Контроллер — сервис — репозиторий | Есть бизнес-сценарии, несколько входных каналов или транзакций | Больше интерфейсов и тестовых границ | Появление пустых обёрток ради шаблона |
| Сервис с доменными компонентами и портами | Сложные правила, внешние системы, независимое тестирование | Самая высокая стоимость проектирования | Избыточная абстракция для простого приложения |
Как провести границы на существующем проекте
Не начинайте с массового переименования файлов. Выберите один сценарий, который часто меняется или регулярно ломается, например оформление заказа или смену тарифа.
- Зафиксируйте вход и выход. Запишите, какие данные приходят в операцию и какой результат нужен вызывающему коду. Это отделяет контракт от текущей реализации.
- Выпишите бизнес-решения. Найдите проверки состояний, прав, лимитов и расчёты. Они должны оказаться в сервисе или вызываемых им доменных компонентах.
- Спрячьте хранилище. Сгруппируйте запросы и преобразование записей в репозитории. Не передавайте ORM-объект через все слои без причины.
- Проверьте направление зависимостей. Контроллер не должен обращаться к базе в обход сервиса, если операция содержит бизнес-правила.
- Добавьте тесты на сценарий. Отдельно проверьте успешный путь, недопустимое состояние, отсутствие объекта и сбой внешней зависимости.
После каждого шага запускайте существующие проверки и сравнивайте поведение API: коды ответов, формат ошибок, побочные записи и повторяемость операции. Архитектурный рефакторинг не должен менять контракт незаметно для клиента.
Что ломается первым
Главная проблема появляется не из-за количества классов, а из-за неясной транзакции. Если сервис вызывает три репозитория, а каждый сам открывает и завершает транзакцию, система может сохранить только часть операции. Граница транзакции должна быть определена на уровне сценария и проверена интеграционным тестом.
Вторая проблема — повторные запросы. Сервис может сначала загрузить заказ, затем репозиторий повторно получить его для проверки, а потом ещё раз прочитать позиции. На небольшом объёме это незаметно, но под нагрузкой растут задержка и блокировки. Измеряйте число запросов, время SQL, долю ожидания соединений и количество откатов, а не только среднее время HTTP-ответа.
Третья — конкурирующие изменения. Два запроса могут одновременно увидеть доступный остаток и оба его зарезервировать. Разделение слоёв эту проблему не решает. Нужны согласованные ограничения базы, блокировки или другая стратегия конкурентного доступа, выбранная под конкретное хранилище.
Когда трёх слоёв недостаточно
Если сервис разросся до сотен строк и координирует расчёт цены, доставку, оплату и уведомления, добавление новых репозиториев не исправит дизайн. Выделите самостоятельные доменные операции или компоненты, а внешние системы подключайте через отдельные адаптеры.
Если приложение состоит из нескольких простых экранов и почти не содержит правил, полноценная архитектура с интерфейсом на каждый класс может замедлить работу команды. Сначала оставьте прямой путь от обработчика к хранилищу, но не смешивайте в нём расчёты и транспортную логику. Усложнять структуру стоит по факту изменения требований, а не заранее.
Материал не привязан к версии языка или фреймворка: названия базовых слоёв и их ответственность не меняются вместе с HTTP-библиотекой. Конкретные способы внедрения зависимостей, управления транзакциями и работы ORM нужно сверять с документацией вашей версии инструмента.
Что делать дальше
Выберите один изменяемый сценарий и нарисуйте его цепочку: входной адаптер, сервис, хранилище, внешние зависимости. Затем измерьте время операции, число запросов к базе и частоту ошибок до рефакторинга. После изменений сравните те же показатели и отдельно проверьте транзакции и повторный вызов.
Следующим шагом настройте наблюдаемость именно вокруг бизнес-операций: логируйте идентификатор сценария, результат и длительность без персональных данных и секретов. Это покажет, какая граница действительно помогает сопровождению, а какая только добавила файлы.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Martin Fowler — Service Layer, Microsoft Azure Architecture Center — N-tier architecture style
Частые вопросы
Нужно ли всегда создавать отдельный сервис и репозиторий?
Можно ли вызывать репозиторий напрямую из контроллера?
Где должна находиться транзакция?
Что делать, если сервис стал слишком большим?
А если у меня другой язык или фреймворк?
Что делать, если после разделения стало больше запросов к базе?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.