Принцип DRY 24.01.2024

Не повторяйся

Довольно часто не очень опытные разработчики боятся дублировать код, стараясь абстрагировать и обобщить все, что только можно. Основная аргументация в том, что есть вот такой принцип, он называется акронимом DRY (Don’t Repeat Yourself), который прямым текстом говорит «не повторяйся». Не повторяйся где, в чем?

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

Здесь стоит обратить внимание на два ключевых термина: «знание» и «система». После этого должно стать понятно, что принцип должен использоваться для более высоких материй.

Этот принцип не имеет никакого отношения к дублированию кода, этот принцип вообще не про код. Более того, он даже не про программирование. Тем не менее глупо отрицать влияние на разработку, так как принцип напрямую влияет на мышление — на мышление как отдельных разработчиков, занимающихся архитектурным проектированием системы, так и на команду разработки в целом.

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

Мы также обязаны следить за актуальностью сопроводительной документации. Если в нашей документации есть несколько мест, хранящих одно и то же знание, нам также потребуется держать в голове все эти места, не забывая затрагивать все из них при изменениях.

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

Немного про SRP

Можно найти множество пересечений между DRY и SRP (Single Responsibility Principle). Кажется, это все об одном и том же, просто выраженное разными людьми разными словами с разных ракурсов. Можно рассматривать их так, что SRP — про конкретный код и его масштабирование и поддержку, а DRY — про все вокруг этого кода.

В своей практике я сталкивался с тем, что разработчик при добавлении нового эндпоинта старается максимально переиспользовать уже существующие DTO (Data Transfer Object) запросов от других эндпоинтов. Как правило, ущербность такого подхода удается доказать только после того, как разработчик прочувствует проблемы лично, но до этого момента разработчик остается верен своим принципам и неверному пониманию DRY.

Давайте рассмотрим логичный, казалось бы, вопрос: если два эндпоинта с разной семантической нагрузкой на вход принимают одни и те же данные, зачем делать эти данные разделенными? Нарушает ли принцип DRY разделение DTO запросов на два разных? Это всегда зависит от самих эндпоинтов и их смысла.

Если DTO (или его часть) не предполагает никаких отдельных изменений в будущем и является универсальным общим знанием, то мы получили нарушение, так как DRY как раз про уменьшение повторяемости этих самых знаний. Такое нарушение приведет к тому, что при изменении набора полей одного DTO придется также внести аналогичную правку и во второй. И, возможно, не забыть и про третий, и четвертый.

Однако, если это знание не универсально, то изменение единого контейнера данных уже нарушает SRP, который гласит, что код должен иметь ровно одну причину изменения. Изменив один DTO запроса одного эндпоинта, мы также проникли своим изменением в зону ответственности второго эндпоинта. Скорее всего, второй эндпоинт теперь имеет также и измененный контракт, что приводит нас к нарушенной обратной совместимости. Хотя исходная задача состояла в том, чтобы изменить один конкретный эндпоинт. Дымовой или интеграционный тест словит эту проблему, но написан ли такой тест?

Даже после того, как входящие данные одного конкретного эндпоинта расширяются дополнительными полями, разработчик все еще уверен, что разделение не требуется. Такой однобокий взгляд на разработку подразумевает, что код пишется «здесь и сейчас ровно один раз», и приводит к тому, что при эволюции бизнеса и/или уточнении требований со стороны клиентов системы начинают появляться nullable-свойства и аргумент-флаги, разделяющие функциональность на разные логические ветки. В том числе и валидацию:

В то же время, разделение разных DTO на разные компоненты приложения позволяет избавить каждый из них от ненужного изменения. Пока разработчик находится в контексте конкретной задачи и помнит об этих нюансах, это может не быть проблемой. Вопрос в том, как скоро разработчик забудет об этом? Как и когда поделится этими знаниями с другими? Конечно, можно обойтись описанием у DTO, где перечислить места, в которых он используется. Однако, это все походит на вынужденное комментирование, которые скорее подчеркивает, что вот тут в коде есть архитектурная проблема, а не несет какой-то полезной информации.

Предположим, что есть два разных эндпоинта создания пользователя. Первый используется для регистрации, второй для создания администратора системы. Несмотря на совершенно разные процессы, происходящие в этих эндпоинтах, каждый из них принимает на вход одинаковый DTO запроса с одинаковыми правилами валидации, а значит разработчик решает выделить единый DTO для этих эндпоинтов, прикрываясь принципом DRY:

final readonly class UserCreateRequestData
{
    #[NotNull]
    #[Length(min = 3, max = 50]
    public string $firstName;

    #[NotBlank(allowNull = true)]
    #[Length(min = 3, max = 50)]
    public string lastName;
}

Данная ситуация не имеет никакого отношения к DRY, так как с точки зрения знаний, процесс регистрации пользователя и процесс создания администратора — два совершенно разных процессах как в бизнес-процессах, так и для клиентского интерфейса (API). В данном случае следовало бы разделить этот DTO на два разных. Все же создание администратора скорее всего также потребует дополнительные данные в будущем.

И немного про DDD

Можно также посмотреть на принцип с точки зрения ограниченных контекстов (bounded context) DDD, так как в рамках одного конкретного ограниченного контекста существует своя бизнес-терминология.

Термин «покупатель» может встречаться как в ограниченном контексте заказа, так и в ограниченном контексте отзыва на товар. При этом набор знаний, свойств и даже бизнес-процессов у них может варьироваться от совершенно различного до совершенно идентичного.

Логично предположить, что имя (без фамилии) покупателя на протяжении всего бизнеса одинаково. Оно везде подчиняется одинаковым бизнес-правилам, которые мы можем выразить, например, в виде предусловий в конструкторе Value Object: допустимая длина, набор допустимых символов и так далее:

final readonly class CustomerName
{
    /**
     * @param non-empty-string $value
     */
    public function __construct(
        public string $value,
    ) {
        Assert::notWhitespaceOnly($value);
        Assert::lengthBetween($value, 1, 100);
    }
}

// где-то в коде
// фамилия и отчество нам не требуются
$name = new CustomerName('Иван');

Принцип не запрещает выделить общую часть в отдельное доменное ядро (domain core) и использовать одновременно в обеих бизнес-сущностях «покупателя». В этом случае решение о дублировании этого Value Object определяется проектировщиком, исходя из перспектив эволюции подсистем и бизнеса в целом, доступных человеческих ресурсов и прочего.

Однако, имя покупателя — это не всегда только имя. В одном ограниченном контексте полное имя покупателя может состоять из имени и фамилии:

final readonly class CustomerName
{
    /**
     * @param non-empty-string $last Фамилия
     * @param non-empty-string $first Имя
     */
    public function __construct(
        public string $last,
        public string $first,
    ) {
        Assert::notWhitespaceOnly($first);
        Assert::lengthBetween($first, 1, 100);

        Assert::notWhitespaceOnly($last);
        Assert::lengthBetween($last, 1, 100);
    }
}

// где-то в коде
// отчество нам не требуется
$name = new CustomerName('Иван', 'Иванов');

В другом ограниченном контексте к фамилии и имени добавляется отчество, причем nullable, так как не у всех оно есть:

final readonly class CustomerName
{
    /**
     * @param non-empty-string $last Фамилия
     * @param non-empty-string $first Имя
     * @param null|non-empty-string $second Отчество
     */
    public function __construct(
        public string $last,
        public string $first,
        public ?string $second,
    ) {
        Assert::notWhitespaceOnly($first);
        Assert::lengthBetween($first, 1, 100);

        Assert::notWhitespaceOnly($last);
        Assert::lengthBetween($last, 1, 100);

        Assert::nullOrNotWhitespaceOnly($second);
        Assert::nullOrLengthBetween($second, 1, 100);
    }
}

// где-то в коде
// а здесь требуем отчество
$name = new CustomerName('Иван', 'Иванов', 'Иванович')

В другом ограниченном контексте требуется только фамилия. Словом, ситуаций и их комбинаций можно придумать множество, все зависит от конкретного бизнеса и моделируемой области.

Выделение в этом случае одного общего Value Object с этими тремя компонентами в доменное ядро будет означать, что в некоторые ограниченные контексты попадут ненужные этому контексту знания, которые могут быть обязательны для заполнения и которые неоткуда взять. Этот неверный шаг проектирования приведет к тому, что фамилия и отчество станут nullable, что уже в определенных контекстах сможет допустить нарушение инвариантности модели, не говоря уже про наличие вынужденных проверок на null:

final readonly class CustomerName
{
    /**
     * @param null|non-empty-string $last Фамилия
     * @param null|non-empty-string $first Имя
     * @param null|non-empty-string $second Отчество
     */
    public function __construct(
        public ?string $last,
        public ?string $first,
        public ?string $second,
    ) {
        Assert::nullOrNotWhitespaceOnly($first);
        Assert::nullOrLengthBetween($first, 1, 100);

        Assert::nullOrNotWhitespaceOnly($last);
        Assert::nullOrLengthBetween($last, 1, 100);

        Assert::nullOrNotWhitespaceOnly($second);
        Assert::nullOrLengthBetween($second, 1, 100);
    }
}

Пример выше наглядно иллюстрирует, что произошло с Value Object имени покупателя. Мы разрешаем таким кодом создавать имя пользователя с null в качестве фамилии там, где фамилия обязательно должна быть заполнена. Мы также вынуждены добавлять проверки на null, чтобы случайно не попасться на неконсистентные данные. И это тоже пример неверного понимания DRY, который может произойти, если разработчик будет бездумно избавляться от дублирующегося, на его взгляд, кода.

Послесловие

Принцип DRY всегда был про нормализацию информации и знаний. Далеко не всегда дублирование кода нарушает DRY, особенно, если по смысловой нагрузке это разные знания, изменяемые из-за разных причин.

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

Уверен, что до книги «Программист-прагматик» все это уже так или иначе существовало, а авторы этой книги только зафиксировали в более официальном виде. Разработчик всегда старается систематизировать знания и практики, придумать им какие-то звучные названия и иметь возможность на них ссылаться. В противном случае все превратилось бы в хаос.