В больших проектах на Битрикс быстро появляется зоопарк: «сервис уведомлений» создаётся в трёх местах, конфиг читается через Option::get прямо из шаблона, а в тестах невозможно подменить внешний API. D7 даёт нормальный путь — Bitrix\Main\DI\ServiceLocator.
Ниже — как зарегистрировать сервис, как достать его из кода, чем locator отличается от «глобального синглтона на коленке», и какие грабли ловят чаще всего на проде.
Зачем ServiceLocator, а не new MyService()
Прямое создание объектов в обработчиках событий и компонентах работает, пока сервис простой. Как только появляются зависимости (логгер, HTTP-клиент, репозиторий), код начинает дублировать инициализацию. Locator решает три задачи:
- одна точка регистрации (модуль или
local/php_interface); - ленивое создание — объект появляется только при первом запросе;
- подмена реализации в тестах или на стейдже без правок бизнес-логики.
Связанные темы на сайте: EventManager, HttpClient, старые события, Composer в Битрикс.
Минимальная регистрация в local/
Для проектной логики удобно регистрировать сервисы в /local/php_interface/init.php (или отдельном файле, который init подключает):
<?php
use Bitrix\Main\DI\ServiceLocator;
use Bitrix\Main\Loader;
Loader::registerAutoLoadClasses(null, [
'Local\\Service\\Notifier' => '/local/php_interface/lib/Service/Notifier.php',
'Local\\Service\\OrderExporter' => '/local/php_interface/lib/Service/OrderExporter.php',
]);
$locator = ServiceLocator::getInstance();
$locator->addInstanceLazy('local.notifier', [
'className' => \\Local\\Service\\Notifier::class,
]);
$locator->addInstanceLazy('local.order.exporter', static function () {
$http = new \\Bitrix\\Main\\Web\\HttpClient([
'socketTimeout' => 5,
'streamTimeout' => 10,
]);
return new \\Local\\Service\\OrderExporter(
ServiceLocator::getInstance()->get('local.notifier'),
$http
);
});
addInstanceLazy не создаёт объект сразу — фабрика или класс вызываются при первом get(). Это важно: тяжёлый клиент к внешней системе не должен подниматься на каждом хите админки.
Класс сервиса без «магии»
Держите сервис обычным PHP-классом. Без статических синглтонов внутри — иначе locator теряет смысл:
<?php
namespace Local\Service;
use Bitrix\Main\Web\HttpClient;
final class OrderExporter
{
public function __construct(
private Notifier $notifier,
private HttpClient $http
) {
}
public function export(int $orderId): void
{
$payload = $this->buildPayload($orderId);
$response = $this->http->post(
'https://erp.example/api/orders',
$payload
);
if ($this->http->getStatus() >= 400) {
$this->notifier->error('ERP reject: ' . $orderId);
throw new \RuntimeException('ERP export failed');
}
$this->notifier->info('Order exported: ' . $orderId);
}
private function buildPayload(int $orderId): array
{
// выборка заказа через D7 ORM / Sale — здесь упрощено
return ['id' => $orderId];
}
}
Как достать сервис из кода
В обработчике события, агенте или контроллере:
<?php
use Bitrix\Main\DI\ServiceLocator;
$exporter = ServiceLocator::getInstance()->get('local.order.exporter');
$exporter->export((int)$orderId);
Проверка наличия — через has(). Если сервиса нет, get() бросит исключение; ловить «на всякий случай» в каждом вызове не нужно — лучше упасть на стейдже, чем молча пропустить экспорт.
$locator = ServiceLocator::getInstance();
if (!$locator->has('local.order.exporter')) {
throw new \LogicException('Service local.order.exporter is not registered');
}
Регистрация из модуля
Если сервис живёт в своём модуле, регистрируйте его при подключении модуля — в include.php или через событие OnPageStart / автозагрузку модуля. Идентификатор лучше неймспейсить именем модуля:
<?php
// /local/modules/vendor.tools/include.php
use Bitrix\Main\DI\ServiceLocator;
$locator = ServiceLocator::getInstance();
$locator->addInstanceLazy('vendor.tools.mailer', [
'constructor' => static function () {
return new \Vendor\Tools\Mailer(
\Bitrix\Main\Config\Option::get('vendor.tools', 'smtp_host')
);
},
]);
Имена вида vendor.tools.mailer читаются в логах и не пересекаются с чужими модулями. Не называйте сервисы просто mailer или cache.
addInstance vs addInstanceLazy
addInstance кладёт уже созданный объект. Это удобно в тестах и в CLI-скриптах, где вы сами собрали зависимости:
$locator = ServiceLocator::getInstance();
$locator->addInstance('local.notifier', new \Local\Service\NullNotifier());
На веб-хите почти всегда нужен lazy: иначе каждый запрос тянет за собой половину интеграций. Правило простое — в runtime используйте lazy, в тестах подставляйте готовый instance.
Связка с EventManager
Типичный прод-паттерн: событие только достаёт сервис и делегирует. Обработчик остаётся тонким.
<?php
use Bitrix\Main\EventManager;
use Bitrix\Main\DI\ServiceLocator;
$em = EventManager::getInstance();
$em->addEventHandler(
'sale',
'OnSaleOrderSaved',
static function (\Bitrix\Main\Event $event) {
$order = $event->getParameter('ENTITY');
if (!$order) {
return;
}
ServiceLocator::getInstance()
->get('local.order.exporter')
->export((int)$order->getId());
}
);
Так проще тестировать экспорт отдельно от жизненного цикла заказа. Подробнее про приоритеты и отладку подписок — в статье про EventManager.
Типичные ошибки
1. Регистрация после первого get()
Если агент или ранний обработчик дергает сервис до того, как init.php успел зарегистрировать его — получите исключение. Регистрацию держите как можно раньше (init модуля / php_interface), а не «рядом с первым использованием» в шаблоне.
2. Сервис хранит состояние запроса
Locator по сути даёт долгоживущий объект на процесс PHP-FPM. Не складывайте в свойства сервиса данные текущего пользователя «навсегда» без явной очистки — на воркерах с несколькими запросами это источник странных багов. Либо делайте сервис stateless, либо передавайте контекст аргументом метода.
3. Циклические зависимости
A тянет B, B тянет A — при первом get() уйдёте в рекурсию. Ломайте цикл: вынесите общее в третий сервис или передавайте зависимость методом, а не в конструкторе.
4. Смешение Option::get и конфигурации сервиса
Читать Option::get внутри каждого метода — шум. Лучше прочитать настройки один раз в фабрике и передать в конструктор. Тогда в тесте можно подставить конфиг без БД.
Отладка: что уже зарегистрировано
Готового «красивого» списка в админке нет. Для отладки на стейдже достаточно временного лога после регистрации:
<?php
$locator = ServiceLocator::getInstance();
foreach (['local.notifier', 'local.order.exporter'] as $id) {
AddMessage2Log(
$id . ' => ' . ($locator->has($id) ? 'yes' : 'no'),
'local.di'
);
}
Если has() = no, проверьте: подключён ли модуль, не упал ли autoload класса, не оборвался ли init на фатале выше по файлу.
Когда locator не нужен
- одноразовый скрипт миграции на 30 строк — обычный
newпонятнее; - чистая функция без зависимостей — не оборачивайте её в сервис «для красоты»;
- значение из инфоблока / заказа — это данные, не сервис.
Locator окупается там, где есть повторяемая инфраструктура: почта, ERP, антифрод, генерация документов, очереди.
Чеклист внедрения
- Вынесите класс сервиса в
/local/php_interface/libили модуль. - Зарегистрируйте lazy-id с префиксом проекта/модуля.
- В обработчиках событий оставляйте только делегирование в сервис.
- Конфиг читайте в фабрике, не в каждом методе.
- На стейдже проверьте
has()и один реальный вызов. - Для тяжёлых побочных эффектов (ERP, почта) добавьте идемпотентность или очередь — locator сам по себе гонки не лечит.
Итог
ServiceLocator — простой DI без внешнего контейнера. Регистрируете сервис один раз, достаёте по строковому id, подменяете в тестах. Вместе с EventManager, Result/Error и нормальной автозагрузкой это убирает половину «скрытых new» из проектного кода и делает Битрикс-проект сопровождаемым дольше, чем один релиз.
