Перестаньте молиться на принципы S.O.L.I.D

47a352316dd5abce22cebf17b11f4620

В мире разработки программного обеспечения существует множество «священных коров» — принципов и практик, которые принимаются как данность и редко подвергаются критическому анализу. Особенно показательна ситуация с принципами SOLID на русскоязычных ресурсах: достаточно открыть Хабр, чтобы найти 100500 статей о SOLID, и в каждой из них принципы интерпретируются по-разному.

Само существование такого количества «объяснительных» статей говорит о фундаментальной проблеме: если принципы требуют толкования, значит их названия не являются самодостаточными и интуитивно понятными. А если каждый разработчик понимает принципы по-своему, возникает вопрос — зачем вообще нужны принципы, которые не дают однозначного руководства к действию? Принципы SOLID, предложенные Робертом Мартином, давно стали одной из таких «священных коров». Однако пришло время честно признать: то, как мы используем SOLID сегодня, часто противоречит изначальным идеям и в целом иногда может приносить больше вреда, чем пользы. Зависит от контекста.


SRP не SRP

Самый яркий пример искажения первоначального замысла — это интерпретация принципа единственной ответственности (SRP).

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

Изначальная идея была вообще про людей: суть в том, чтобы разные заказчики изменений могли влиять на систему независимо друг от друга, не создавая конфликтов. Бухгалтеры меняют формулу расчета бонуса, технари — какие-то технические подходы к базе, и они не мешают друг другу. То есть, если над одним классом работают разные бизнес-юниты, это признак нарушения SRP.

Ответственность в том смысле, что если ты испортил класс, и тебя за это может уволить и CTO, и CFO, то у класса две ответственности, и надо его разделять.

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

Иногда небольшой понятный класс разбивается на 5 частей ради «единственности ответственности» в вакууме, да еще и добавляются абстракции, чтобы удовлетворить DIP.

Является ли такое разделение действительно необходимым? Иногда да, но точно не всегда.


Принцип открытости/закрытости

Принцип открытости/закрытости (OCP) — более странного названия и придумать сложно, новички стабильно впадают в ступор. Объяснение «программные сущности должны быть открыты для расширения, но закрыты для модификации» этот ступор только удваивает. Опять же, фанатичное следование этому правилу может переусложнить ваш код. Например,

// Плохо - нарушает OCP
class OrderCalculator {
    calculateTotal(order: Order) {
        if (order.type === 'retail') {
            return this.calculateRetailTotal(order);
        } else if (order.type === 'wholesale') {
            return this.calculateWholesaleTotal(order);
        }
        // При добавлении нового типа заказа придется менять этот код
    }
}
// Хорошо - следует OCP
interface OrderCalculator {
    calculateTotal(order: Order): number;
}

class RetailCalculator implements OrderCalculator {
    calculateTotal(order: Order): number { ... }
}

class WholesaleCalculator implements OrderCalculator {
    calculateTotal(order: Order): number { ... }
}
// Новые типы калькуляторов можно добавлять без изменения существующего кода

Тут всегда вопрос в том, а появится ли еще один тип заказа в будущем? Или мы просто так добавили 2 класса и интерфейс (+ еще где-то фабрика нужна, if просто туда переедет)? Может пока что обойдемся этим ифом, да и хрен с ним?

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

Если это простой продуктовый код (а не универсальная библиотека), то зачастую гораздо правильнее абстракции выделять по мере возникновения реальной потребности. Добавился новый тип чего-нибудь (или точно будет в будущем) — увидели что с этим много возни стало — добавили абстракцию.


Liskov Substitution Principle

Представим, что у нас есть базовый класс для чтения данных из файла:

class FileReader {
    read(filePath: string): string {
        // Читаем файл и возвращаем его содержимое
        return fs.readFileSync(filePath, 'utf8');
    }
}

И мы хотим создать специализированный класс для чтения зашифрованных файлов:

class EncryptedFileReader extends FileReader {
    read(filePath: string): string {
        // Здесь возникает проблема с LSP
        if (!this.isFileEncrypted(filePath)) {
            throw new Error("File is not encrypted!");
        }

        const content = super.read(filePath);
        return this.decrypt(content);
    }

    private isFileEncrypted(filePath: string): boolean {
        // Проверка, зашифрован ли файл
    }

    private decrypt(content: string): string {
        // Расшифровка содержимого
    }
}

Строго следуя LSP, этот код проблематичен, потому что:

EncryptedFileReader усиливает предусловия (требует зашифрованный файл)
Выбрасывает исключение в ситуации, когда базовый класс работал бы нормально

Чтобы соблюсти LSP, нам пришлось бы:

Либо заставить базовый класс проверять, зашифрован ли файл (что не имеет смысла для обычного чтения)

Либо создать общий интерфейс и два независимых класса вместо наследования

interface IFileReader {
    read(filePath: string): string;
}

class PlainFileReader implements IFileReader {
    read(filePath: string): string {
        return fs.readFileSync(filePath, 'utf8');
    }
}

class EncryptedFileReader implements IFileReader {
    read(filePath: string): string {
        if (!this.isFileEncrypted(filePath)) {
            throw new Error("File is not encrypted!");
        }
        const content = fs.readFileSync(filePath, 'utf8');
        return this.decrypt(content);
    }
    // ...
}

В данном случае строгое следование LSP привело к:

Увеличению количества кода

Дублированию логики чтения файла

Усложнению структуры проекта

При этом изначальная версия с наследованием, хоть и нарушает LSP, более практична и понятна. В реальных проектах такое решение может быть предпочтительнее, особенно если мы уверены, что EncryptedFileReader не будет использоваться в контексте, где ожидается поведение базового FileReader. Т.е. принцип подстановки Лисков для начала подразумевает, что эта подстановка есть, ну или скорее всего точно будет.


Interface Segregation Principle: Размер имеет значение

ISP часто интерпретируется как «делайте интерфейсы маленькими», что приводит к взрыву количества микроинтерфейсов:

interface Readable {
    String read();
}

interface Writable {
    void write(String data);
}

interface Closeable {
    void close();
}

class FileHandler implements Readable, Writable, Closeable { ... }

Такое разделение может показаться элегантным, но иногда оно создает ненужную сложность и затрудняет понимание системы в целом. Зависит от конектста (банально, но правда).


Dependency Inversion Principle: Инверсия ради инверсии

DIP часто превращается в догму «всегда используйте интерфейсы», что приводит к созданию ненужных абстракций. И вот уже весь код набит интерфейсами, которые имплементируются ровно один раз в одном классе и только затрудняют навигацию. «На всякий случай»

Особенно непонятно зачем это делать для программы на Go, где зачастую интерфейс можно прикрутить в любой момент позже (там «классы» не требуют указания «implements»).


Что делать?


  1. Контекст важнее правил
    Вместо слепого следования принципам SOLID, нужно всегда учитывать контекст конкретного проекта. Маленькому проекту не нужна сложная архитектура корпоративного приложения.


  2. Простота — главное достоинство
    Если код можно написать проще — его нужно писать проще. Не стоит создавать сложные абстракции только потому, что «так говорит SOLID».


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



Более глубокий взгляд на проблему

Важно понимать, что проблема не столько в самих принципах SOLID, сколько в том, как индустрия их использует. Мы превратили их в догмы и часто применяем механически, без понимания изначального контекста и целей. Показательный пример — повсеместное создание абстракций «про запас». На код ревью вас ожидает жесткая порка, если, не дай бог, класс делает сразу две вещи.

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

SOLID задумывались именно как набор эвристик для решения конкретных проблем, а не как незыблемые правила. Роберт Мартин предложил их как способы решения определённых проблем, с которыми он сталкивался в конкретных проектах. Со временем индустрия превратила их в своего рода религию, потеряв первоначальный контекст и прагматичный подход.


Заключение

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

Приглашаю вас подписаться на мой канал в telegram

© Habrahabr.ru