16.01.2026 15 мин чтения

Service (сервисный слой) в Битрикс: куда вынести бизнес-логику

В 1С‑Битрикс Service (сервисный слой) — это место, где живут бизнес‑правила и «сценарии использования» (use cases): оформление заказа, расчёт скидки, изменение статуса заявки, отправка уведомления, проверка прав, транзакции.
Он помогает отделить доменную логику от инфраструктуры Битрикса (компоненты, контроллеры, CIBlockElement, ORM, события).

Service (сервисный слой) в Битрикс: куда вынести бизнес-логику

Если вы уже используете 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 в понятные сценарии.
Опубликовано 9 месяцев назад

Не сходится?

Спросите Домового: он знает документацию и материалы сайта по этой теме. Ответит сразу.

Кирилл Новожилов

Нужен живой разбор?

Консультация автора BXMax: созвон один на один, 90 минут на вашу задачу по теме «Service (сервисный слой) в Битрикс: куда вынести бизнес-логику».

Выбрать время Ближайшее: 10 ноября, вторник, 20:00–21:30 по Москве

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

Пожалуйста, войдите в аккаунт, чтобы оставить комментарий

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

Похожие статьи

Мы используем файлы cookie для улучшения работы сайта. Продолжая использовать сайт, вы соглашаетесь с нашей политикой конфиденциальности.
AI Домовой

AI Домовой История

на связи

пишет…

Можно закрыть чат и перейти на другую страницу. Когда ответ будет готов, на кнопке Домового появится отметка.

Нет истории чатов
AI Домовой

Нужна авторизация

Войдите, чтобы задавать вопросы AI Домовому.

Войти