В модулях на D7 почти каждая операция заканчивается одним из двух исходов: успех с данными или ошибка с кодом и текстом. Если размазывать это по false, null, исключениям и строкам «что-то пошло не так», вызывающий код превращается в лотерею. Стандартный ответ Битрикса — BitrixMainResult и коллекция BitrixMainError.
Ниже — рабочий минимум: как собирать Result, класть туда данные и ошибки, пробрасывать из сервиса в контроллер/агент и не путать транспортный сбой с бизнес-ошибкой. Связка с ServiceLocator, HttpClient, EventManager и Cache/TaggedCache получается естественной.
Зачем Result, а не false/throw везде
false говорит только «не получилось». Исключение рвёт стек даже там, где ошибка ожидаема (нет остатка на складе, дубль email, отказ внешней API). Result держит оба сценария в одном объекте:
isSuccess()— итоговый флаг;getData()/setData()— полезный payload;getErrors()— списокErrorс кодом и сообщением;- удобно склеивать несколько шагов (
addErrors,mergeчерез ручную сборку).
Исключения оставляйте для действительно аварийных ситуаций: нет модуля, сломан конфиг, нарушение контракта. Бизнес-отказы — в Result.
Минимальный сервис, который возвращает Result
<?php
namespace LocalOrders;
use BitrixMainError;
use BitrixMainResult;
final class OrderSubmitService
{
public function submit(int $orderId, array $payload): Result
{
$result = new Result();
if ($orderId <= 0) {
$result->addError(new Error('Некорректный ID заказа', 'ORDER_ID_INVALID'));
return $result;
}
if (empty($payload['email']) || !filter_var($payload['email'], FILTER_VALIDATE_EMAIL)) {
$result->addError(new Error('Нужен валидный email', 'EMAIL_INVALID'));
// можно накапливать несколько ошибок до раннего return
}
if (!$result->isSuccess()) {
return $result;
}
// ... сохранение, внешний вызов ...
$result->setData([
'orderId' => $orderId,
'status' => 'submitted',
'externalId' => 'EXT-1001',
]);
return $result;
}
}
Вызывающий код не гадает, что означает false: смотрит isSuccess(), читает getErrorMessages() или коды из getErrors().
Чтение результата: данные, сообщения, коды
<?php
use LocalOrdersOrderSubmitService;
$service = new OrderSubmitService();
$result = $service->submit(42, ['email' => 'bad']);
if (!$result->isSuccess()) {
// человекочитаемые строки
$messages = $result->getErrorMessages();
// ["Нужен валидный email"]
foreach ($result->getErrors() as $error) {
// код удобен для API/фронта: EMAIL_INVALID
$code = $error->getCode();
$text = $error->getMessage();
// $error->getCustomData() — произвольный массив, если задавали
}
return;
}
$data = $result->getData();
// ['orderId' => 42, 'status' => 'submitted', ...]
Код ошибки лучше держать стабильным (UPPER_SNAKE), текст — локализуемым. Так REST и UI могут ветвиться по коду, а сообщение показывать пользователю.
Несколько ошибок и кастомные данные
Валидация часто находит сразу пачку проблем. Result это поддерживает без костылей:
<?php
use BitrixMainError;
use BitrixMainResult;
$result = new Result();
$result->addErrors([
new Error('Пустой телефон', 'PHONE_EMPTY'),
new Error('Регион не обслуживается', 'REGION_DENIED', [
'region' => 'XX',
'hint' => 'доступны MO, SPB',
]),
]);
if (!$result->isSuccess()) {
foreach ($result->getErrors() as $error) {
$extra = $error->getCustomData(); // array|null
}
}
Третий аргумент конструктора Error — customData. Кладите туда id сущности, поле формы, сырой ответ API — всё, что поможет отладке или UI, но не обязано быть в тексте сообщения.
Склейка шагов: HTTP → парсинг → сохранение
Типичный пайплайн интеграции: запрос через HttpClient, разбор JSON, запись в ORM. Каждый шаг может вернуть свой Result; ошибки копируем дальше:
<?php
use BitrixMainError;
use BitrixMainResult;
use BitrixMainWebHttpClient;
function fetchRemoteOrder(string $url): Result
{
$result = new Result();
$http = new HttpClient([
'socketTimeout' => 5,
'streamTimeout' => 15,
]);
$body = $http->get($url);
$status = $http->getStatus();
if ($body === false || $status !== 200) {
$result->addError(new Error(
'Не удалось получить заказ',
'REMOTE_HTTP_FAIL',
['status' => $status, 'errors' => $http->getError()]
));
return $result;
}
$data = json_decode($body, true);
if (!is_array($data) || empty($data['id'])) {
$result->addError(new Error('Битый JSON заказа', 'REMOTE_JSON_INVALID'));
return $result;
}
$result->setData(['remote' => $data]);
return $result;
}
function importOrder(string $url): Result
{
$result = new Result();
$remote = fetchRemoteOrder($url);
if (!$remote->isSuccess()) {
$result->addErrors($remote->getErrors());
return $result;
}
$payload = $remote->getData()['remote'];
// ... ORM add/update ...
$result->setData(['importedId' => (int)$payload['id']]);
return $result;
}
Так агент или кнопка в админке получают единый контракт: либо данные, либо список ошибок с кодами. Логируйте customData, пользователю показывайте getErrorMessages().
ORM и Result: add/update уже отдают Result
Многие методы D7 ORM возвращают AddResult / UpdateResult / DeleteResult — наследники Result. Не выбрасывайте их значение «в никуда»:
<?php
use BitrixIblockElementTable;
use BitrixMainResult;
function saveElement(array $fields): Result
{
$add = ElementTable::add($fields);
if (!$add->isSuccess()) {
// ошибки ORM уже внутри: дубли CODE, NOT NULL и т.п.
return $add;
}
$result = new Result();
$result->setData(['id' => (int)$add->getId()]);
return $result;
}
Если сверху нужна своя ошибка с бизнес-кодом — скопируйте $add->getErrors() и добавьте свои через addError / addErrors.
События: Result из OnBefore*
В обработчиках OnBefore* часто нужно остановить сохранение. Паттерн с Event и Result привычен ядру:
<?php
use BitrixMainEvent;
use BitrixMainEventResult;
use BitrixMainError;
// упрощённо: вернуть EventResult::ERROR с Error
$event = new Event('main', 'onBeforeSomething', $params);
$event->send();
foreach ($event->getResults() as $eventResult) {
if ($eventResult->getType() === EventResult::ERROR) {
$errors = $eventResult->getParameters();
// дальше — в свой Result или ShowError
}
}
Подробнее про подписки и приоритеты — в разборе EventManager. Здесь важно не смешивать: EventResult — для шины событий, Result — для сервисов и ORM. На границе слоя конвертируйте явно.
REST/AJAX: стабильный JSON-ответ
<?php
use BitrixMainEngineController;
use BitrixMainEngineResponseAjaxJson;
use BitrixMainError;
use BitrixMainResult;
final class OrderController extends Controller
{
public function submitAction(int $orderId, array $payload): AjaxJson
{
$serviceResult = (new LocalOrdersOrderSubmitService())
->submit($orderId, $payload);
if (!$serviceResult->isSuccess()) {
// Engine сам умеет ErrorCollection — можно пробросить ошибки контроллера
foreach ($serviceResult->getErrors() as $error) {
$this->addError($error);
}
return AjaxJson::createError($this->errorCollection);
}
return AjaxJson::createSuccess($serviceResult->getData());
}
}
Фронт получает единый формат: success + data или errors[]. Не изобретайте параллельный {ok: false, msg: "..."}, если уже сидите на bitrix/services/main/ajax.php.
Типичные ошибки
- Игнорируют
isSuccess()и сразу читаютgetData()— на ошибке data пустой или частичный; сначала флаг. - Кидают Exception на каждую валидацию — ломают транзакции и агенты; для ожидаемых отказов используйте Error.
- Тексты без кодов — UI и мониторинг не могут отличить «нет stock» от «таймаут API».
- Глотают ORM Result:
ElementTable::add($f);без проверки — тихий провал на проде. - Мешают HttpClient false и Result — транспортный сбой оборачивайте в Error с
customData, не возвращайте сыройfalseиз сервиса. - Логируют только строку — пишите код + customData (status, orderId), иначе разбор инцидента займёт час.
Чеклист перед мержем сервиса
- Публичные методы сервиса возвращают
Result(или наследника), а не mixed. - У каждой ошибки есть стабильный
code. - Успех всегда кладёт данные через
setData, даже если это один id. - Вложенные Result копируют ошибки через
addErrors. - ORM add/update/delete проверяются через
isSuccess(). - На границе HTTP/контроллера ошибки мапятся в AjaxJson / REST error, без потери кода.
Вывод
BitrixMainResult — тонкий, но полезный контракт между слоями модуля. Сервис говорит «получилось / нет» языком ядра, ORM уже так устроен, контроллеры Engine понимают те же Error. Если выстраиваете DI через ServiceLocator и ходите наружу через HttpClient, Result — логичная третья деталь того же стека: без зоопарка false, строк и случайных исключений.
