Service (сервисный слой) в Битрикс: куда вынести бизнес-логику
В 1С‑Битрикс Service (сервисный слой) — это место, где живут бизнес‑правила и «сценарии использования» (use cases): оформление заказа, расчёт скидки, изменение статуса заявки, отправка уведомления, проверка прав, транзакции.
Он помогает отделить доменную логику от инфраструктуры Битрикса (компоненты, контроллеры, CIBlockElement, ORM, события).
Кирилл Новожилов
Автор
Содержание
Если вы уже используете DTO и Repository, сервисный слой становится «клеем», который связывает их в понятные сценарии.
Проблема: бизнес-логика живёт в компонентах, контроллерах и событиях
Типичная картина в Bitrix‑проекте:
- в компоненте: и чтение данных, и проверки, и запись, и отправка писем;
- в
Controller: валидация, права, выборки, формат ответа, бизнес‑правила — всё вместе; - в обработчике события: ещё один кусок сценария, который зависит от формы данных «как пришло».
В итоге «точка правды» о правилах расползается: где-то проверяем статус, где-то нет; где-то оборачиваем в транзакцию, где-то забыли; где-то кэшируем, где-то — снова грузим.
// Типичная ситуация: контроллер стал бизнес-логикой и доступом к данным одновременно
final class Order extends \Bitrix\Main\Engine\Controller
{
public function createAction(array $fields): ?array
{
\Bitrix\Main\Loader::includeModule('sale');
// валидация в стиле "как получится"
if (empty($fields['USER_ID']) || empty($fields['ITEMS'])) {
$this->addError(new \Bitrix\Main\Error('Invalid request'));
return null;
}
// бизнес-правила + инфраструктура
if ((int)$fields['USER_ID'] <= 0) {
$this->addError(new \Bitrix\Main\Error('User not found'));
return null;
}
// ... выборки, расчёты скидок, запись заказа, письма, логирование ...
return ['ok' => true];
}
}
Почему это проблема
1. SRP ломается
Компонент/контроллер начинает отвечать за HTTP, за формат ответа, за бизнес‑правила, за транзакции и за Bitrix API.
2. Трудно тестировать
Когда логика привязана к Request, глобальным функциям и CIBlockElement, её почти невозможно покрыть unit‑тестами.
3. Нельзя переиспользовать сценарий
Один и тот же сценарий (например, «создать заявку») нужен и в контроллере, и в консоли, и в агенте, и в событии. Если логика в контроллере — придётся копировать.
4. Баги от рассинхрона
Правило «нельзя менять статус из paid обратно в new» в одном месте есть, в другом — забыли.
Решение: выделить Service слой
Service — это класс, который:
- инкапсулирует бизнес‑правила и последовательность шагов сценария;
- координирует репозитории, внешние сервисы (почта, очередь, логирование), транзакции;
- не знает про HTTP и шаблоны (это работа контроллера/компонента);
- возвращает понятный результат (DTO/
Result) вместо «массивов как вышло».
Если Repository — это «как достать/сохранить», то Service — это «что сделать по правилам предметной области».
Когда использовать Service
Service полезен не всегда. Он нужен там, где есть сценарий, а не просто «прочитать и вывести».
Используйте Service когда:
- бизнес‑правила повторяются в нескольких местах;
- действие затрагивает несколько сущностей/хранилищ (товары + скидки + заказ);
- нужны транзакции и согласованность (all‑or‑nothing);
- важны права доступа и инварианты (нельзя нарушить состояние);
- хочется отвязаться от Bitrix API внутри правил.
Можно обойтись без Service когда:
- это простой read‑only вывод списка без правил;
- локальная админская форма «один раз сохранить поле»;
- быстрый прототип (с осознанным техдолгом).
Анатомия сервисного слоя в Bitrix
Базовая связка: Controller/Component → Service → Repository → DTO
Идеальный «поток» выглядит так:
┌───────────────────────────────────────────┐
│ Controller / Component (HTTP/UI) │
│ Парсит запрос, вызывает сервис, │
│ превращает результат в ответ │
└───────────────────┬───────────────────────┘
│ DTO / primitives
▼
┌───────────────────────────────────────────┐
│ Service │
│ Бизнес-логика и сценарии: │
│ проверки, транзакции, координация │
└───────────────────┬───────────────────────┘
│ calls
▼
┌───────────────────────────────────────────┐
│ Repository │
│ Доступ к данным, маппинг наружу DTO │
└───────────────────┬───────────────────────┘
│ uses
▼
┌───────────────────────────────────────────┐
│ Bitrix storage (Iblock/ORM/Sale) │
└───────────────────────────────────────────┘
Где живёт валидация и маппинг
Чаще всего удобно разделять:
- структурную валидацию (обязательные поля, форматы) — в DTO (
fromRequest()/ конструктор); - бизнес‑валидацию (инварианты, права, допустимые статусы) — в Service.
Важно про маппинг: Если у DTO только одна точка входа (например, конкретный экшен контроллера), маппинг данных из массива удобно инкапсулировать прямо в DTO через статический метод (как в примере ниже). Если же источников данных много (API, импорт, старое ядро), лучше использовать отдельный Mapper, как мы рассматривали в статье про DTO.
Пример: OrderService с Result, транзакцией и репозиториями
Ниже упрощённый пример: сервис создаёт заказ, валидирует бизнес‑правила, делает транзакцию и возвращает Result.
DTO запроса (вход)
final class OrderCreateDto
{
/**
* @param array<int, array{productId:int, quantity:int}> $items
*/
public function __construct(
public readonly int $userId,
public readonly array $items,
public readonly ?string $comment = null,
) {
$this->validate();
}
public static function fromRequest(array $fields): self
{
$userId = (int)($fields['USER_ID'] ?? 0);
$items = is_array($fields['ITEMS'] ?? null) ? $fields['ITEMS'] : [];
$comment = isset($fields['COMMENT']) ? (string)$fields['COMMENT'] : null;
// нормализация items
$normalized = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$normalized[] = [
'productId' => (int)($item['PRODUCT_ID'] ?? 0),
'quantity' => (int)($item['QUANTITY'] ?? 0),
];
}
return new self(userId: $userId, items: $normalized, comment: $comment);
}
private function validate(): void
{
if ($this->userId <= 0) {
throw new \InvalidArgumentException('USER_ID must be positive');
}
if (empty($this->items)) {
throw new \InvalidArgumentException('ITEMS must not be empty');
}
foreach ($this->items as $item) {
if ($item['productId'] <= 0 || $item['quantity'] <= 0) {
throw new \InvalidArgumentException('Invalid item: productId/quantity');
}
}
}
}
DTO результата (выход)
final class OrderDto
{
public function __construct(
public readonly int $id,
public readonly int $userId,
public readonly float $totalPrice,
public readonly string $status,
) {}
}
Репозитории (интерфейсы)
interface UserRepositoryInterface
{
public function existsById(int $id): bool;
}
interface ProductRepositoryInterface
{
public function getPriceById(int $productId): ?float;
public function canBuy(int $productId, int $quantity): bool;
}
interface OrderRepositoryInterface
{
/**
* @param array<int, array{productId:int, quantity:int, price:float}> $items
*/
public function create(int $userId, array $items, ?string $comment): OrderDto;
}
Service
use Bitrix\Main\Application;
use Bitrix\Main\Error;
use Bitrix\Main\Result;
final class OrderService
{
public function __construct(
private readonly UserRepositoryInterface $users,
private readonly ProductRepositoryInterface $products,
private readonly OrderRepositoryInterface $orders,
) {}
public function create(OrderCreateDto $dto): Result
{
$result = new Result();
// бизнес-валидация / инварианты
if (!$this->users->existsById($dto->userId)) {
return $result->addError(new Error('User not found', 'USER_NOT_FOUND'));
}
$pricedItems = [];
foreach ($dto->items as $item) {
$productId = $item['productId'];
$qty = $item['quantity'];
if (!$this->products->canBuy($productId, $qty)) {
return $result->addError(new Error('Product is not available', 'PRODUCT_NOT_AVAILABLE'));
}
$price = $this->products->getPriceById($productId);
if ($price === null) {
return $result->addError(new Error('Product price not found', 'PRICE_NOT_FOUND'));
}
$pricedItems[] = ['productId' => $productId, 'quantity' => $qty, 'price' => $price];
}
// транзакция: сервис отвечает за согласованность сценария
$connection = Application::getConnection();
$connection->startTransaction();
try {
$order = $this->orders->create($dto->userId, $pricedItems, $dto->comment);
// здесь могут быть дополнительные шаги сценария:
// - списание бонусов
// - отправка события/уведомления (лучше через очереди/пост-обработку)
// - запись аудита
$connection->commitTransaction();
$result->setData(['order' => $order]);
return $result;
} catch (\Throwable $e) {
$connection->rollbackTransaction();
return $result->addError(new Error($e->getMessage(), 'ORDER_CREATE_FAILED'));
}
}
}
Контроллер: тонкий слой, только HTTP
Контроллер превращает запрос в DTO, вызывает сервис и формирует ответ. Вся бизнес‑логика остаётся в сервисе.
use Bitrix\Main\Engine\Controller;
use Bitrix\Main\Error;
final class Order extends Controller
{
public function createAction(array $fields): ?array
{
try {
$dto = OrderCreateDto::fromRequest($fields);
} catch (\InvalidArgumentException $e) {
$this->addError(new Error($e->getMessage(), 'VALIDATION_ERROR'));
return null;
}
$service = $this->getOrderService(); // DI/ServiceLocator
$result = $service->create($dto);
if (!$result->isSuccess()) {
foreach ($result->getErrors() as $error) {
$this->addError($error);
}
return null;
}
/** @var OrderDto $order */
$order = $result->getData()['order'];
return [
'id' => $order->id,
'userId' => $order->userId,
'totalPrice' => $order->totalPrice,
'status' => $order->status,
];
}
private function getOrderService(): OrderService
{
// Упрощённо. В реальном проекте: DI контейнер/ServiceLocator модуля.
throw new \RuntimeException('Not implemented');
}
}
Типичные ошибки сервисного слоя
1) «Service на всё» (God Service)
// Плохо: один сервис знает про всё и делает всё
final class AppService
{
public function doEverything(array $data): array { /* 1500 строк */ }
}
Хорошо: делите сервисы по сценариям/поддоменам: OrderService, PricingService, ProductAvailabilityService, UserAccessService.
2) Сервис зависит от HTTP/компонента
// Плохо: сервис тянет Request и формирует Response
public function create(\Bitrix\Main\HttpRequest $request): array {}
Хорошо: сервис принимает DTO/примитивы, возвращает DTO/Result.
3) Сервис делает доступ к данным напрямую через Bitrix API
// Плохо: сервис протекает инфраструктурой
\CIBlockElement::GetList(...);
Хорошо: сервис вызывает репозитории (а детали API остаются внутри репозитория).
4) Сервис = «обёртка над репозиторием»
// Плохо: сервис просто проксирует методы repo без правил
public function getById(int $id) { return $repo->findById($id); }
Если в сервисе нет правил и сценария — возможно, сервис не нужен (или правила просто ещё не выделены).
5) Побочные эффекты без границ
Отправка писем, логирование, внешние запросы прямо внутри транзакции — частая причина «подвисаний» и случайных повторов.
Практика: транзакция — только для согласованного состояния данных. Всё тяжёлое — после commit (или в очереди).
Рекомендации по внедрению
1) Начните с одного сценария
Возьмите «создание заявки/заказа» и вынесите правила в Service. Не пытайтесь «переписать всё на сервисы» сразу.
2) Держите границы
- Controller/Component: HTTP/UI, ошибки/ответ
- Service: бизнес‑правила, транзакции, сценарий
- Repository: доступ к данным
- DTO: структура/валидация данных
3) Используйте Result для ошибок
Bitrix\Main\Result и Error удобно «складываются» в контроллере и дают единый формат ошибок.
4) Подумайте про DI
Сервис удобнее тестировать и поддерживать, если зависимости приходят через конструктор (репозитории, адаптеры внешних сервисов). Подробнее о том, как реализовать это через стандартный ServiceLocator в Битрикс, мы писали в отдельном гайде.
5) Не делайте сервисы статическими (static)
Статика в Bitrix часто приводит к скрытым зависимостям и сложным побочным эффектам.
Service слой в Битрикс — это практичный способ:
- локализовать бизнес‑правила в одном месте,
- сделать контроллеры/компоненты тонкими и стабильными,
- подготовить проект к росту, рефакторингу и миграциям,
- естественно связать DTO и Repository в понятные сценарии.
Теги:
Похожие статьи
Repository в Битрикс: как перестать дергать CIBlockElement из бизнес-логики