Использование DTO в Битрикс: чистая архитектура и типобезопасность
DTO (Data Transfer Object) — паттерн проектирования, который помогает структурировать данные при передаче между слоями приложения. В контексте 1С-Битрикс 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 для товаров, заказов, пользователей. Добавьте маппинг и репозитории. Со временем выработаете свои паттерны и почувствуете, насколько проще становится поддерживать код.
Похожие статьи