3

Bitrix D7 Result и Error: контракт успеха и ошибок в модулях

В модулях на 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
    }
}

Третий аргумент конструктора ErrorcustomData. Кладите туда 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), иначе разбор инцидента займёт час.

Чеклист перед мержем сервиса

  1. Публичные методы сервиса возвращают Result (или наследника), а не mixed.
  2. У каждой ошибки есть стабильный code.
  3. Успех всегда кладёт данные через setData, даже если это один id.
  4. Вложенные Result копируют ошибки через addErrors.
  5. ORM add/update/delete проверяются через isSuccess().
  6. На границе HTTP/контроллера ошибки мапятся в AjaxJson / REST error, без потери кода.

Вывод

BitrixMainResult — тонкий, но полезный контракт между слоями модуля. Сервис говорит «получилось / нет» языком ядра, ORM уже так устроен, контроллеры Engine понимают те же Error. Если выстраиваете DI через ServiceLocator и ходите наружу через HttpClient, Result — логичная третья деталь того же стека: без зоопарка false, строк и случайных исключений.