13.01.2026 15 мин чтения

Использование DTO в Битрикс: чистая архитектура и типобезопасность

DTO (Data Transfer Object) — паттерн проектирования, который помогает структурировать данные при передаче между слоями приложения. В контексте 1С-Битрикс DTO позволяют избавиться от хаоса ассоциативных массивов, добавить типизацию и сделать код понятнее и надёжнее.

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

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

Автор

Использование DTO в Битрикс: чистая архитектура и типобезопасность

Проблема: массивы повсюду

Откройте любой проект на Битрикс — и вы увидите ассоциативные массивы везде. Они приходят из GetList, уходят в шаблоны, передаются между функциями. На первый взгляд это удобно: не нужно создавать классы, данные легко модифицировать.

Но со временем проявляются проблемы:

        // Типичный код в Битрикс
function processProduct($product)
{
    // Какие ключи есть в $product?
    // Какого они типа?
    // Может ли PRICE быть null?
    // А если ключа вообще нет?

    $name = $product['NAME'];           // Угадываем структуру
    $price = $product['PRICE'];         // Надеемся, что число
    $weight = $product['WEIGHT'];       // А вдруг это строка?
}

    

Почему массивы — это проблема

1. IDE не помогает

При работе с массивом IDE не знает его структуру. Нет автодополнения, нет подсказок по типам, нет навигации к определению. Вы остаётесь один на один с документацией (если она есть) или исходным кодом.

2. Ошибки обнаруживаются поздно

Опечатка в ключе массива — это не ошибка компиляции. Вы узнаете о ней только когда код выполнится. А если это редко используемая ветка — возможно, уже на продакшене.

        $arResult['PROPERTEIS'];  // Опечатка — узнаем только в runtime

    

3. Неявные контракты

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

4. Сложность рефакторинга

Нужно переименовать ключ PRICE в BASE_PRICE? Удачи с поиском по всему проекту. И надейтесь, что не пропустили динамически формируемые ключи.

5. Отсутствие валидации

Массив примет что угодно. Отрицательную цену? Пожалуйста. Пустое имя? Без проблем. Ошибки всплывут позже, в неожиданных местах.

Решение: Data Transfer Object

DTO — это простой объект, предназначенный для переноса данных. В отличие от сущностей, DTO не содержит бизнес-логики. Его задача — структурировать данные и гарантировать их корректность.

        final class ProductDto
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly float $price,
        public readonly int $quantity,
    ) {}
}

    

Теперь IDE знает всё о структуре данных, типы проверяются автоматически, а код документирует сам себя.

Ключевые преимущества DTO

1. Типобезопасность

PHP 8+ строго проверяет типы при создании объекта. Попытка передать строку вместо числа вызовет TypeError — сразу, а не где-то в глубине бизнес-логики.

        // Ошибка обнаружится сразу при создании
$product = new ProductDto(
    id: "123",        // TypeError: must be int
    name: 'Товар',
    price: 1500.00,
    quantity: 10,
);

    

2. Автодополнение и навигация

IDE понимает структуру DTO. Вы получаете автодополнение свойств, переход к определению, рефакторинг с переименованием по всему проекту.

3. Самодокументируемость

Сигнатура DTO — это документация. Посмотрев на класс, вы сразу понимаете структуру данных, типы полей и какие из них обязательны.

4. Иммутабельность

С readonly свойствами данные нельзя случайно изменить после создания объекта. Это исключает целый класс ошибок, связанных с неожиданными мутациями.

5. Валидация при создании

В конструкторе можно проверить бизнес-правила. Невалидные данные просто не превратятся в объект.

6. Явные контракты

Функция, принимающая DTO, явно декларирует, какие данные ей нужны. Это контракт, который проверяется компилятором.

        // Контракт очевиден из сигнатуры
function calculateDiscount(ProductDto $product): float
{
    // Гарантированно есть id, name, price, quantity
    // Гарантирована правильная типизация
}

    

Когда использовать DTO

DTO полезны не везде. Вот критерии, когда стоит их применять:

Используйте DTO когда:

  • Данные передаются между слоями (контроллер → сервис → репозиторий)
  • Данные имеют чёткую структуру, которую нужно документировать
  • Важна валидация входных данных
  • Структура используется в нескольких местах
  • Вы работаете с внешними API (входящими или исходящими)

Можно обойтись без DTO когда:

  • Данные используются локально в одной функции
  • Структура тривиальна (1-2 поля)
  • Это временные промежуточные данные

Анатомия DTO

Базовая структура

Минимальный DTO в PHP 8+:

        <?php
namespace Vendor\Module\Dto;

final class ProductDto
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly float $price,
        public readonly ?string $description = null,
    ) {}
}

    

Ключевые элементы:

  • final — DTO не предназначены для наследования
  • readonly — свойства нельзя изменить после создания
  • Типизация всех свойств
  • Опциональные свойства имеют значения по умолчанию

DTO с валидацией

Добавьте проверку бизнес-правил в конструктор:

        final class OrderDto
{
    public function __construct(
        public readonly int $userId,
        public readonly float $totalPrice,
        public readonly string $status,
        public readonly array $items,
    ) {
        $this->validate();
    }

    private function validate(): void
    {
        if ($this->userId <= 0) {
            throw new \InvalidArgumentException('User ID must be positive');
        }

        if ($this->totalPrice < 0) {
            throw new \InvalidArgumentException('Total price cannot be negative');
        }

        if (empty($this->items)) {
            throw new \InvalidArgumentException('Order must have at least one item');
        }

        $allowedStatuses = ['new', 'processing', 'completed', 'cancelled'];
        if (!in_array($this->status, $allowedStatuses, true)) {
            throw new \InvalidArgumentException('Invalid order status');
        }
    }
}

    

Теперь невозможно создать заказ с отрицательной ценой или без товаров — ошибка произойдёт сразу при попытке создания.

Типы DTO по назначению

В реальных проектах иногда нужны разные DTO для одной сущности в зависимости от операции. Но не всегда — важно понимать, когда это оправдано.

Когда хватит одного DTO

Если ваша сущность простая и операции с ней однотипны — используйте один DTO:

        // Для простого справочника хватит одного класса
final class CityDto
{
    public function __construct(
        public readonly ?int $id,  // null при создании
        public readonly string $name,
        public readonly string $code,
    ) {}
}

    

Один DTO достаточен когда:

  • Структура данных при чтении и записи почти одинаковая
  • Нет сложной логики частичного обновления
  • Сущность имеет мало полей (до 5-7)

Когда нужны отдельные DTO

Разделение оправдано, когда структуры данных существенно отличаются:

Read DTO (для чтения)

Полный набор данных для отображения:

        final class ProductDto
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $code,
        public readonly float $price,
        public readonly ?float $oldPrice,
        public readonly int $quantity,
        public readonly bool $available,
        public readonly array $images,
        public readonly \DateTimeImmutable $createdAt,
    ) {}

    public function hasDiscount(): bool
    {
        return $this->oldPrice !== null && $this->oldPrice > $this->price;
    }

    public function getDiscountPercent(): int
    {
        if (!$this->hasDiscount()) {
            return 0;
        }
        return (int)round(100 - ($this->price / $this->oldPrice * 100));
    }
}

    
Важно Методы hasDiscount() и getDiscountPercent() — это не бизнес-логика, а удобные вычисляемые свойства на основе данных DTO.

Create DTO (для создания)

Минимум данных, необходимых для создания сущности:

        final class ProductCreateDto
{
    public function __construct(
        public readonly string $name,
        public readonly string $code,
        public readonly int $iblockId,
        public readonly ?float $price = null,
        public readonly ?int $quantity = null,
        public readonly ?int $sectionId = null,
    ) {
        $this->validate();
    }

    private function validate(): void
    {
        if (empty(trim($this->name))) {
            throw new \InvalidArgumentException('Product name is required');
        }

        if (!preg_match('/^[a-z0-9_-]+$/i', $this->code)) {
            throw new \InvalidArgumentException('Invalid product code format');
        }
    }
}

    

Здесь нет id (он ещё не существует) и createdAt (заполнится автоматически).

Update DTO (для обновления)

Частичное обновление — нужно отличать «не передано» от «передано null»:

        final class ProductUpdateDto
{
    private const UNSET = '__UNSET__';

    public function __construct(
        public readonly int $id,
        public readonly string|null $name = self::UNSET,
        public readonly float|null $price = self::UNSET,
        public readonly bool|null $active = self::UNSET,
    ) {}

    public function shouldUpdate(string $field): bool
    {
        return $this->$field !== self::UNSET;
    }

    public function getChangedFields(): array
    {
        $fields = [];
        foreach (['name', 'price', 'active'] as $prop) {
            if ($this->shouldUpdate($prop)) {
                $fields[$prop] = $this->$prop;
            }
        }
        return $fields;
    }
}

    

Это решает проблему: как отличить «цену не обновляем» от «обнуляем цену»?

Разделяйте DTO когда:

  • При чтении много вычисляемых полей, которых нет при создании
  • Нужно частичное обновление (PATCH-семантика)
  • Валидация при создании и обновлении отличается
  • Сущность сложная, с множеством связей

Архитектура с DTO

Слои приложения

DTO естественно вписываются в слоистую архитектуру:

        ┌─────────────────────────────────────────┐
│            Controller                   │
│  Принимает запрос, создаёт DTO          │
└─────────────────┬───────────────────────┘
                  │ ProductCreateDto
                  ▼
┌─────────────────────────────────────────┐
│             Service                     │
│  Бизнес-логика, работает с DTO          │
└─────────────────┬───────────────────────┘
                  │ ProductCreateDto
                  ▼
┌─────────────────────────────────────────┐
│            Repository                   │
│  Преобразует DTO ↔ хранилище            │
└─────────────────┬───────────────────────┘
                  │ ProductDto
                  ▼
┌─────────────────────────────────────────┐
│             Mapper                      │
│  Преобразует DTO ↔ массивы Битрикс      │
└─────────────────────────────────────────┘

    

Структура модуля

Рекомендуемая организация файлов:

        /local/modules/vendor.catalog/
├── lib/
│   ├── Dto/
│   │   ├── ProductDto.php
│   │   ├── ProductCreateDto.php
│   │   └── ProductUpdateDto.php
│   ├── Mapper/
│   │   └── ProductMapper.php
│   ├── Repository/
│   │   └── ProductRepository.php
│   ├── Service/
│   │   └── ProductService.php
│   └── Controller/
│       └── Product.php

    

Роль Mapper

Mapper — это класс, отвечающий за преобразование между DTO и другими форматами. Он изолирует знание о структуре данных Битрикс:

        class ProductMapper
{
    public function fromBitrixElement(array $arElement): ProductDto
    {
        return new ProductDto(
            id: (int)$arElement['ID'],
            name: (string)$arElement['NAME'],
            price: (float)($arElement['CATALOG_PRICE_1'] ?? 0),
            // ... остальные поля
        );
    }

    public function toCreateArray(ProductCreateDto $dto): array
    {
        return [
            'IBLOCK_ID' => $dto->iblockId,
            'NAME' => $dto->name,
            'CODE' => $dto->code,
            'ACTIVE' => 'Y',
        ];
    }

    public function toApiArray(ProductDto $dto): array
    {
        return [
            'id' => $dto->id,
            'name' => $dto->name,
            'price' => $dto->price,
            'hasDiscount' => $dto->hasDiscount(),
        ];
    }
}

    

Изменилась структура ответа API? Меняете только Mapper. Изменилось хранение в инфоблоке? Опять только Mapper.

Роль Repository

Repository инкапсулирует работу с хранилищем и возвращает DTO.

Важно В примере используется старое ядро (CIBlockElement), которое до сих пор встречается во многих проектах. Если вы работаете с D7 ORM (Bitrix\Iblock\Elements), принцип остаётся тем же — меняется только внутренняя реализация Repository.
        class ProductRepository
{
    private ProductMapper $mapper;

    public function findById(int $id): ?ProductDto
    {
        // Пример на старом ядре — в D7 используйте компилируемые классы инфоблоков
        $rsElements = \CIBlockElement::GetList(
            [],
            ['IBLOCK_ID' => $this->iblockId, 'ID' => $id],
            false,
            false,
            $this->selectFields
        );

        if ($arElement = $rsElements->GetNextElement()) {
            $fields = $arElement->GetFields();
            $fields['PROPERTIES'] = $arElement->GetProperties();
            return $this->mapper->fromBitrixElement($fields);
        }

        return null;
    }

    public function create(ProductCreateDto $dto): ProductDto
    {
        $fields = $this->mapper->toCreateArray($dto);

        $element = new \CIBlockElement();
        $id = $element->Add($fields);

        if (!$id) {
            throw new \RuntimeException($element->LAST_ERROR);
        }

        return $this->findById($id);
    }
}

    

Код, использующий Repository, не знает ничего о CIBlockElement, SQL или ORM. Он работает только с DTO. Это и есть главное преимущество — при миграции на D7 меняется только Repository, остальной код остаётся без изменений.

Роль Service

Service содержит бизнес-логику и координирует работу:

        class ProductService
{
    public function __construct(
        private ProductRepository $repository,
        private ProductMapper $mapper,
    ) {}

    public function createProduct(ProductCreateDto $dto): Result
    {
        $result = new Result();

        // Бизнес-правило: код должен быть уникален
        $existing = $this->repository->findByCode($dto->code);
        if ($existing !== null) {
            $result->addError(new Error('Product code already exists'));
            return $result;
        }

        $product = $this->repository->create($dto);
        $result->setData(['product' => $product]);

        return $result;
    }
}

    

Интеграция с контроллерами Битрикс

Контроллер создаёт DTO из запроса и передаёт в сервис:

        class Product extends \Bitrix\Main\Engine\Controller
{
    public function createAction(array $fields): ?array
    {
        try {
            $dto = ProductCreateDto::fromRequest($fields);
        } catch (\InvalidArgumentException $e) {
            $this->addError(new Error($e->getMessage(), 'VALIDATION_ERROR'));
            return null;
        }

        $service = new ProductService();
        $result = $service->createProduct($dto);

        if (!$result->isSuccess()) {
            foreach ($result->getErrors() as $error) {
                $this->addError($error);
            }
            return null;
        }

        $product = $result->getData()['product'];
        return $this->mapper->toApiArray($product);
    }
}

    

Обратите внимание на разделение ответственности:

  • Контроллер отвечает за HTTP-слой: парсинг запроса, формирование ответа
  • DTO отвечает за валидацию структуры данных
  • Сервис отвечает за бизнес-логику
  • Repository отвечает за работу с хранилищем

Вложенные DTO

Для сложных структур используйте композицию:

        final class AddressDto
{
    public function __construct(
        public readonly string $city,
        public readonly string $street,
        public readonly string $building,
        public readonly ?string $apartment,
    ) {}

    public function getFullAddress(): string
    {
        $parts = [$this->city, $this->street, $this->building];
        if ($this->apartment) {
            $parts[] = "кв. {$this->apartment}";
        }
        return implode(', ', $parts);
    }
}

final class CustomerDto
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
        public readonly ?AddressDto $address,
    ) {}
}

final class OrderDto
{
    public function __construct(
        public readonly int $id,
        public readonly CustomerDto $customer,
        public readonly AddressDto $deliveryAddress,
        public readonly array $items,
        public readonly float $total,
    ) {}
}

    

Вложенность DTO отражает реальную структуру данных и делает её очевидной.

Коллекции DTO

Для списков DTO создавайте обёртки с пагинацией:

        final class ProductListDto
{
    /**
     * @param ProductDto[] $items
     */
    public function __construct(
        public readonly array $items,
        public readonly int $totalCount,
        public readonly int $page,
        public readonly int $pageSize,
    ) {}

    public function getTotalPages(): int
    {
        return (int)ceil($this->totalCount / $this->pageSize);
    }

    public function hasNextPage(): bool
    {
        return $this->page < $this->getTotalPages();
    }

    public function isEmpty(): bool
    {
        return empty($this->items);
    }
}

    

Типичные ошибки

1. Бизнес-логика в DTO

        // Плохо — DTO не должен знать о скидках и их расчёте
class ProductDto
{
    public function applyDiscount(float $percent): void
    {
        $this->price = $this->price * (1 - $percent / 100);
    }
}

// Хорошо — логика в сервисе
class PricingService
{
    public function applyDiscount(ProductDto $product, float $percent): ProductDto
    {
        return new ProductDto(
            id: $product->id,
            price: $product->price * (1 - $percent / 100),
            // ...
        );
    }
}

    

2. Мутабельные DTO

        // Плохо — можно случайно изменить
class ProductDto
{
    public float $price;
}

// Хорошо — иммутабельный
final class ProductDto
{
    public function __construct(
        public readonly float $price,
    ) {}
}

    

3. Один DTO для всего

        // Плохо — один DTO для чтения, создания и обновления
class ProductDto
{
    public ?int $id;           // null при создании
    public ?string $name;      // null при частичном обновлении
    public ?float $price;      // непонятно — не передали или обнулили?
}

// Хорошо — отдельные DTO для разных операций
class ProductDto { /* для чтения */ }
class ProductCreateDto { /* для создания */ }
class ProductUpdateDto { /* для обновления */ }

    

4. Отсутствие валидации

        // Плохо — принимает любые данные
class ProductDto
{
    public function __construct(
        public readonly float $price,
    ) {}
}

// Хорошо — валидация в конструкторе
class ProductDto
{
    public function __construct(
        public readonly float $price,
    ) {
        if ($price < 0) {
            throw new \InvalidArgumentException('Price cannot be negative');
        }
    }
}

    

Рекомендации

1. Используйте readonly и final

Иммутабельность — ключевое свойство DTO. Используйте readonly для свойств и final для класса.

2. Именованные аргументы

Всегда используйте именованные аргументы при создании DTO:

        $product = new ProductDto(
    id: 1,
    name: 'Товар',
    price: 1500.00,
);

    

3. Валидация в конструкторе

Проверяйте бизнес-правила сразу. Невалидный объект не должен существовать.

4. Фабричные методы для источников данных

Создавайте fromArray(), fromRequest(), fromEntity() для разных источников.

5. Документируйте массивы

        /**
 * @param ProductDto[] $items
 */
public function __construct(
    public readonly array $items,
) {}

    

6. Разделяйте DTO по операциям

Read, Create, Update, Filter — разные классы для разных задач.

Заключение

DTO — это инвестиция в качество кода. Да, нужно создавать дополнительные классы. Да, нужно писать маппинг. Но взамен вы получаете:

  • Типобезопасность — ошибки обнаруживаются при компиляции, а не в продакшене
  • Самодокументируемость — структура данных очевидна из кода
  • Надёжность — валидация при создании, иммутабельность после
  • Удобство разработки — автодополнение, рефакторинг, навигация
  • Чистую архитектуру — явное разделение слоёв и ответственности

Начните с основных сущностей проекта. Создайте DTO для товаров, заказов, пользователей. Добавьте маппинг и репозитории. Со временем выработаете свои паттерны и почувствуете, насколько проще становится поддерживать код.

Опубликовано 3 недели назад

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

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