← назад к разделу

SOLID и GRASP — про проектирование классов. Принципы из этой статьи — шире: они про инженерные решения вообще — от строчки кода до выбора, нужна ли вам ещё одна служба. Их часто цитируют как заклинания; здесь — что каждый значит на самом деле, где у него границы и как он выглядит в коде.

Обязательно

DRY — не повторяй знание

Представьте: правило «заказ дороже 10 000 ₽ — бесплатная доставка» записано в трёх местах — в обработчике, в SQL-отчёте и в форме на фронтенде. Порог повысили до 15 000 ₽ — сделали три правки, и одну забыли. Пользователи получают бесплатную доставку, когда не должны.

DRY (Don't Repeat Yourself) — каждая единица знания должна иметь одно авторитетное место в системе. Формулировка — из книги «The Pragmatic Programmer» Эндрю Ханта и Дэвида Томаса (1999).

порог доставки записан трижды обработчик SQL-отчёт форма 10 000 ₽ 10 000 ₽ 10 000 ₽ порог подняли до 15 000 ₽ 15 000 ₽поправили 15 000 ₽поправили правку забыли один из трёх путей раздаёт доставку по старому порогу — и об этом никто не знает то же знание в одном месте правило доставки15 000 ₽ обработчикSQL-отчётформа все трое спрашиваютправка одна — расходиться нечему

Сверху три копии одного знания: две поправили, третью забыли — и система тихо живёт с двумя разными порогами. Снизу то же знание вынесено в одно место: менять его теперь негде, кроме как там.

Ключевое слово — знание, не текст. DRY не нарушен, когда два куска кода совпадают текстуально, но несут разные знания. Лимит длины имени покупателя и лимит длины названия товара могут оба равняться 255 — но это два независимых решения, и склеивать их в одну константу MAX_NAME_LENGTH вредно: изменение одного потащит другое.

public final class CustomerName {
    private static final int MAX_LENGTH = 255; // независимое решение
}

public final class ProductTitle {
    private static final int MAX_LENGTH = 255; // тоже независимое
}

Склейка случайно похожих вещей в одну абстракцию — самая дорогая форма нарушения DRY. Потом каждое изменение требует if-параметров, потому что «общая» константа оказывается не такой уж общей.

Практическое правило — правило трёх: терпите дублирование до третьего повторения. К третьему разу станет видно, что в нём общее знание, а что просто совпадение.

KISS — из работающих решений выбирай простейшее

Каждая дополнительная концепция в коде — налог на каждого будущего читателя. KISS (Keep It Simple) ровно про это: простота измеряется числом концепций, которые нужно держать в голове, чтобы понять код. Реактивное программирование добавляет нетривиальные типы и другой стиль отладки — и оправдано только тогда, когда профиль нагрузки этого требует. Очередь задач на таблице PostgreSQL со SKIP LOCKED проще брокера сообщений — если событий тысяча в час, брокер «на вырост» означает отдельный кластер, мониторинг и команду, которая в нём разбирается.

Тест при ревью: можно ли объяснить решение коллеге за минуту? Generic-фабрика стратегий с рефлексией, спрятанная за тремя интерфейсами, проигрывает обычному switch — даже если она «расширяемее».

Обратная сторона: простое — не то же самое, что примитивное. Решение бывает простым сегодня и дорогим через полгода. Один класс на все виды документов с полем type и десятком if внутри объясняется за минуту, а на пятом виде документа превращается в то, что боятся трогать. Разница между простым и примитивным в том, за чей счёт достигнута простота: простое решение экономит концепции у читателя, примитивное экономит работу автору сегодня и перекладывает её на того, кто будет менять код завтра.

Рабочий вопрос при выборе: что станет с этим решением, когда таких случаев станет пять? Если ответ «ничего, добавится ещё одна реализация» — оно простое. Если «придётся переписать» — примитивное, и переписывать будет уже не тот, кто сэкономил.

YAGNI — не строй до потребности

Разработчик делает конфигурируемость «на будущее», хотя второго потребителя пока нет. Добавляет поле version в API, которым никто не версионирует. Разворачивает сложную многослойную архитектуру для прототипа, у которого ещё нет ни одного реального пользователя.

YAGNI (You Aren't Gonna Need It) — не делай сейчас то, что нужно потом. Правило выросло из практик экстремального программирования конца 1990-х. Всё это — ставки на будущее, которые обычно не сбываются, а обслуживания требуют уже сегодня.

KISS — про форму решения, YAGNI — про время: не «сделай проще», а «не делай сейчас».

Важная граница: YAGNI — про функциональность, не про качество. Тесты, понятные имена и миграции со стратегией отката «понадобятся потом» всегда — на них принцип не распространяется.

Второй рабочий критерий — обратимость. Ставки на будущее различаются не размером, а ценой отмены. Назвать колонку status вместо is_active, вынести правило в отдельный метод, добавить индекс — дёшево отменить: не пригодилось, откатили за час. Разделить сервис надвое, выбрать брокер сообщений, завести отдельную базу под отчёты — отменять придётся месяцами, и обычно не отменяют, а живут с этим.

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

Separation of Concerns — разные аспекты в разных модулях

Поменяли формат ответа — пришлось трогать бизнес-логику; поменяли базу данных — трогаем HTTP-слой. Так выглядит код без разделения обязанностей: метод в бизнес-логике сам разбирает HTTP-заголовки, сам открывает транзакцию, сам форматирует ответ.

Separation of Concerns — разные аспекты системы должны жить в разных модулях: смешение делает каждый из них неизменяемым без риска сломать другой. Сам термин ввёл Эдсгер Дейкстра ещё в 1974 году.

OrderService разбор запроса заголовки и коды транзакция открыть и закрыть формат ответа JSON наружу

Три разные причины для правки сходятся в один класс; после разделения каждая уезжает в свой слой.

Транзакции, безопасность, кеширование, метрики отделяются от бизнес-логики аспектами: вокруг объекта встаёт заместитель и делает своё до и после вызова. Плата за такое разделение — невидимая в коде граница: по телу метода не видно, что вокруг него открылась транзакция, а вызов соседнего метода того же объекта пройдёт мимо заместителя — и пометка не сработает. HTTP-разбор отделён от обработки контроллером и конвертерами. Конфигурация — от кода профилями.

Признак смешения — импорт через границу: HTTP-типы в бизнес-логике, объекты базы данных в контроллере.

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.jdbc.core.JdbcTemplate;

public class OrderService {

    private final JdbcTemplate jdbc;

    public void create(HttpServletRequest request) {
        String customerId = request.getHeader("X-Customer-Id");
        jdbc.update("insert into orders(customer_id) values (?)", customerId);
    }
}

Шапка файла и есть диагноз: HTTP-тип и работа с базой в одном классе означают, что смена формата запроса и смена схемы таблицы правят один и тот же файл, и правят его разные люди по разным поводам. После разделения контроллер достаёт customerId из запроса и передаёт дальше обычной строкой, обработчик знает только про заказ, репозиторий — только про SQL, и ни один из трёх не пересобирается из-за изменений в двух других.

Law of Demeter — не лезь во внутренности чужих объектов

Распространённая ситуация: метод получает заказ, потом залезает в адрес покупателя, потом в город адреса — и так несколько уровней глубины.

// нарушение — лезем во внутренности через несколько уровней
var city = order.getCustomer().getAddress().getCity();

Чем глубже цепочка, тем сильнее связанность: OrderService теперь знает о внутреннем устройстве Customer и Address. Стоит переименовать поле в Address — ломается код в OrderService.

через цепочку OrderService Order Customer Address через соседа OrderService Order

Сверху вызывающий знает все четыре класса, снизу только заказ; переименование поля в Address ломает лишь верхний путь.

Law of Demeter — метод вызывает методы своих полей, параметров, локальных объектов и тех, что создал сам, но не «друзей друзей». Правило сформулировали в Северо-Восточном университете в 1987 году, в проекте Demeter — от него и имя.

живой пример

public class ShippingCityDemo {
    record Address(String city) {}
    record Customer(Address address) {
        String shippingCity() {
            return address.city();
        }
    }
    record Order(Customer customer) {
        String shippingCity() {
            return customer.shippingCity();
        }
    }

    public static void main(String[] args) {
        Order order = new Order(new Customer(new Address("Казань")));
        // так не надо: вызывающий знает про Customer и Address
        System.out.println("через цепочку: " + order.customer().address().city());
        // так надо: спрашиваем у ближайшего объекта
        System.out.println("спросили у заказа: " + order.shippingCity());
    }
}
Запустить

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

Ответ один и тот же, а знаний у вызывающего кода разное количество. Обратите внимание: цепочка не спрятана внутрь Order, а разобрана — каждый объект спрашивает только своего ближайшего соседа, заказ покупателя, покупатель адрес. Если бы Order.shippingCity() сам писал customer.address().city(), нарушение никуда не делось бы, а просто переехало этажом ниже.

Важная оговорка: fluent API (builder.queryParam(...).build()) и Stream-конвейеры — не нарушение. Там каждый вызов возвращает тот же builder или трансформирует значения, а не лезет в чужие внутренности. Закон — про структурную связанность, а не про количество точек в строке.

Composition over Inheritance — делегируй, не наследуй

Представьте AbstractOrderService с методом process, который вызывает validate. Каждый наследник переопределяет validate. Потом нужно переиспользовать валидацию в другом сервисе — и выясняется, что она прибита к иерархии. Или выходит новая версия фреймворка, и AbstractOrderService меняет внутренний порядок вызовов — все наследники неожиданно ломаются.

Composition over Inheritance — переиспользуй поведение через делегирование, а не через наследование классов. Это прямая рекомендация банды четырёх: «предпочитайте композицию объектов наследованию классов» (1994).

живой пример

public class OrderProcessorDemo {
    record Order(String id) {}

    // так не надо: проверка прибита к иерархии
    static abstract class BaseOrderService {
        abstract void validate(Order order);

        void process(Order order) {
            validate(order);
        }
    }

    // так надо: проверка приходит снаружи
    interface OrderValidator {
        void validate(Order order);
    }

    static class OrderProcessor {
        private final OrderValidator validator;

        OrderProcessor(OrderValidator validator) {
            this.validator = validator;
        }

        void process(Order order) {
            validator.validate(order);
        }
    }

    public static void main(String[] args) {
        new BaseOrderService() {
            void validate(Order order) {
                System.out.println("наследник проверил " + order.id());
            }
        }.process(new Order("ord-42"));

        OrderValidator shared = order -> System.out.println("тот же валидатор проверил " + order.id());
        new OrderProcessor(shared).process(new Order("ord-43"));
        System.out.println("валидатор существует отдельно — его можно отдать любому обработчику");
    }
}
Запустить

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

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

Когда наследование уместно: точки расширения, которые библиотека или фреймворк сам спроектировал под наследование (Template Method по явному контракту). Своё прикладное поведение — всегда через композицию.

Fail Fast — ошибка должна проявляться рано

null прокрался через пять слоёв и всплыл как NullPointerException в чужом модуле в пятницу ночью на проде. На поиск причины ушло три часа, потому что ошибка возникла далеко от того места, где сломалось.

старт конфиг не проверен запрос вход не проверен создание объект с null внутри слой 4 null едет дальше слой 5 NullPointerException

Ни один рубеж не остановил null, и он всплыл на пятом слое, далеко от места, где его создали.

Fail Fast — ошибка должна проявиться как можно раньше и как можно ближе к своей причине.

Три рубежа:

  • Старт приложения — некорректная конфигурация должна валить сервис при запуске, а не при первом запросе на проде.
  • Граница запроса — входные данные проверяются до бизнес-логики.
  • Конструктор объекта — объект не должен существовать в некорректном состоянии.

живой пример

import java.math.BigDecimal;
import java.util.Currency;
import java.util.Objects;

public class MoneyDemo {
    record Money(BigDecimal amount, Currency currency) {
        Money {
            Objects.requireNonNull(amount);
            Objects.requireNonNull(currency);
            if (amount.scale() > currency.getDefaultFractionDigits()) {
                throw new IllegalArgumentException(
                    "разрядов %d больше, чем допускает %s".formatted(amount.scale(), currency));
            }
        }
    }

    public static void main(String[] args) {
        Currency rub = Currency.getInstance("RUB");
        System.out.println(new Money(new BigDecimal("199.00"), rub));
        try {
            new Money(new BigDecimal("199.00001"), rub);
        } catch (IllegalArgumentException e) {
            System.out.println("конструктор не пустил: " + e.getMessage());
        }
    }
}
Запустить

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

Сумма с лишними разрядами не доживает до бизнес-логики: объекта просто не существует. Антипод — «оборонительное» глотание: catch (Exception e) { log.error(...); return null; }. Такой код превращает раннюю ошибку в позднюю и уничтожает контекст, который помог бы её диагностировать.

Где принцип не работает — и это стоит знать заранее, потому что применённый не туда он ломает вещи.

Первое — ввод от пользователя. Форма с ошибкой в одном поле не повод уронить запрос: её собирают целиком и возвращают человеку все ошибки сразу. Ранняя проверка тут остаётся, а «падение» превращается в ответ с перечнем проблем.

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

Третье — вызовы чужих служб. Таймаут означает не «данные плохие», а «у соседа плохой день», и правильный ответ — повтор с выдержкой, а не отказ.

Формулировка, которая разводит эти случаи: Fail Fast — про ошибку программиста и про некорректное состояние, которого быть не должно, а не про ожидаемые неприятности окружения. Отрицательная сумма в конструкторе Money — первое. Недоступный платёжный шлюз — второе.

Principle of Least Astonishment — код делает то, что обещает

Метод называется getOrder(), но заодно меняет статус заказа. GET-эндпоинт с побочным эффектом. equals, сравнивающий по ссылке у типа-значения. Всё это работает, но врёт читателю.

Principle of Least Astonishment — код делает то, что обещает по сигнатуре и конвенциям стека; удивление читателя — признак дефекта дизайна.

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

Конвенции — прикладная форма принципа: query-методы не должны изменять состояние; GET-запрос должен быть безопасным, а PUT — идемпотентным. Нарушение этих конвенций ломает кеши и логику повторных запросов, которые на них рассчитывают.

Самый дешёвый способ соблюдать принцип — следовать конвенциям стека, а не изобретать свои: каждое «у нас принято иначе» — будущее удивление.

Самая частая форма нарушения — побочный эффект в геттере. getOrder(), который заодно подгружает связи, пишет в кеш или меняет статус, ломает не только чтение кода, но и отладку: отладчик в IDE сам вычисляет геттеры, чтобы показать поля объекта, — и эффект случается от одного наведения мыши, в момент, когда программа стоит на точке останова. Ошибки, которые пропадают под отладчиком или, наоборот, появляются только под ним, чаще всего отсюда.

Отдельный вопрос — что делать, когда конвенции спорят: в предметной области слово значит одно, а в стеке принято другое. Побеждает та сторона, которую читатель встретит чаще. Внутри доменной модели выигрывает язык предметной области: если в страховании это полис, метод называют по полису, даже когда фреймворк вокруг оперирует «сущностями». На границе со стеком выигрывает конвенция стека: обработчик HTTP-запроса называют так, как принято во фреймворке, потому что его читают вместе с чужим кодом, а не с вашим. И в обоих случаях выбор записывают в одном месте — в словаре проекта, — чтобы следующий спор не начинался с нуля.

Бритва Оккама — не плоди сущности сверх необходимого

KISS и бритва Оккама звучат как одно правило, но режут разное. KISS смотрит внутрь решения: из работающих вариантов бери тот, который проще объяснить. Бритва смотрит на состав системы: каждая её часть должна быть зачем-то нужна. Поэтому код, безупречный по KISS, прекрасно уживается с лишним брокером сообщений рядом — и наоборот, стройная система из трёх частей может быть набита запутанным кодом.

Второй микросервис, когда один домен и одна команда. Redis при ста запросах в секунду, «потому что масштабирование». Отдельный общий модуль ради трёх утилит. Брокер сообщений для трёх событий в день.

Бритва Оккама в проектировании: каждая сущность — сервис, брокер, база, библиотека, слой, паттерн — должна оправдывать своё существование.

В отладке работает так же: из гипотез «баг в среде выполнения», «баг во фреймворке», «баг в моём вчерашнем коммите» начинать стоит с последней — простейшее объяснение чаще оказывается верным.

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

Когда принципы спорят друг с другом

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

DRY против разделения обязанностей. Два модуля используют один и тот же порог бесплатной доставки. Вынесли в общую константу — убрали дублирование и одновременно связали модули: теперь изменение в одном автоматически меняет поведение другого, даже если этого не хотели. Разрешается вопросом «это одно знание или два совпавших?»: если порог по своей природе один и меняется одним решением — общее место оправдано; если каждый модуль вправе передумать сам — пусть будут две константы, и это не нарушение DRY.

Fail Fast против устойчивости. Строгая проверка на входе делает ошибку видимой сразу, но на границе с внешним миром строгость означает отказ обслуживать. Граница проходит по источнику данных: свой код и своя конфигурация — падать сразу, чужие данные и чужие службы — обрабатывать и повторять.

KISS против защиты от изменений. Стабильный интерфейс вокруг точки изменений — это лишний слой, который читателю придётся пройти. Пока провайдер один и менять его не собираются, прямой вызов проще и честнее; после второй смены провайдера дешевле оказывается слой. Критерий не «красивее», а «сколько раз это уже менялось».

YAGNI против того, что нельзя отменить. Не делать сейчас — хороший совет ровно до тех пор, пока решение обратимо. Выбор формата хранения данных, разделение на службы и публичный контракт — то, за что потом платят годами, и здесь стоит подумать заранее, даже когда потребности ещё нет.

Закон Деметры против пустых делегирований. Буквальное следование закону порождает цепочку методов-посредников, каждый из которых просто пробрасывает вызов на этаж ниже. Это тот случай, когда принцип применён к форме, а не к сути: закон про то, чтобы не знать чужого устройства, а не про то, чтобы у каждого объекта был метод на каждый случай.

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

Дополнительно: при первом чтении можно пропустить

Глубже: неизменяемость как проектное решениерасширенное

Неизменяемость мелькала в фазе четыре раза по касательной: у Flyweight, у Fail Fast, у value objects. Пора сказать прямо: объект, который нельзя изменить после создания, это не синтаксическая мелочь, а решение, которое упрощает всё остальное.

Что даёт. Неизменяемый объект проверяют один раз, в конструкторе, и дальше он корректен всегда: Money с отрицательной суммой просто не существует. Его можно отдавать наружу без страха, что получатель испортит: не нужны защитные копии при каждом геттере. Он безопасен между потоками без замков, годится в ключи HashMap и в кэш, его сравнение по значению очевидно. И код читается иначе: где нет setStatus, нет и вопроса «кто и когда это изменил».

Как делать. record даёт неизменяемость по умолчанию и правильные equals и hashCode. У обычного класса поля final, конструктор проверяет инварианты, коллекции внутри копируют (List.copyOf), иначе передавший список изменит его снаружи, и это называется защитным копированием. Изменение состояния выражают новым объектом: order.withStatus(PAID) возвращает копию, а не меняет исходник; record с одним методом with… закрывает это в пять строк.

Где остановиться. Неизменяемыми делают значения и объекты передачи: деньги, периоды, адреса, команды, события, DTO. Сущности с жизненным циклом (заказ проходит состояния) остаются изменяемыми, но меняются только через свои методы, которые проверяют переход. Копирование больших объектов на каждое изменение стоит памяти, и там, где изменений тысячи в секунду, изменяемый объект под замком честнее. Но по умолчанию ответ «неизменяемо», и менять его нужно с причиной, а не по привычке ставить сеттер.

Глубже: тестопригодность как критерий дизайнарасширенное

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

Механика такая. Чтобы протестировать метод, нужно управлять его входом и наблюдать выход. Всё, что метод берёт не из аргументов и полей, а сам, new PaymentClient() внутри, статический вызов Clock.now(), чтение из System.getenv, обращение к синглтону, недоступно тесту: заменить это нечем, и тест либо идёт в настоящую платёжку, либо не пишется. Место, где дизайн допускает подмену, называют швом: конструктор с интерфейсом вместо new, параметр Clock вместо статического времени, Supplier<String> вместо переменной окружения. Внедрение зависимостей из фазы про Spring это ровно способ сделать швы везде и сразу.

Отсюда практические признаки. Если для теста нужно поднять базу, значит логика перемешана с доступом к данным: разделите на чистую функцию и тонкий слой ввода-вывода. Если тесту нужно замокать пять зависимостей, класс делает пять дел, и это SRP. Если проверить поведение можно только через приватное поле или рефлексию, у объекта нет нужного метода или наблюдаемого результата. Если тест зависит от порядка выполнения, у кода общее состояние.

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

Коротко

  • DRY — одно знание в одном месте; случайное совпадение текста — не нарушение DRY.
  • KISS и бритва Оккама — про экономию на разных уровнях: из работающих решений бери то, что проще объяснить, и не заводи сущность, которая себя не оправдывает; простое — не то же самое, что примитивное.
  • YAGNI — не строй до реальной потребности; на тесты и понятные имена не распространяется.
  • Separation of Concerns и Law of Demeter — про границы: разные аспекты в разных модулях (импорт через границу — признак смешения), метод работает с ближайшими объектами и не лезет цепочкой в чужое устройство.
  • Composition over Inheritance — переиспользуй через делегирование; наследование уместно только там, где фреймворк явно под него спроектирован.
  • Fail Fast — ошибка дешевле ранняя; конструктор, граница запроса, старт приложения — три рубежа.
  • Least Astonishment — код делает то, что обещает по сигнатуре и конвенциям стека.
  • Неизменяемость по умолчанию: record или final-поля, проверка в конструкторе, List.copyOf, изменение через with…; изменяемыми остаются сущности с жизненным циклом.
  • Принципы спорят друг с другом: DRY связывает модули, Fail Fast мешает устойчивости, KISS спорит с защитой от изменений; выбирают по той боли, которая уже есть.
  • Трудный тест это плохой дизайн: new и статические вызовы внутри метода не оставляют швов для подмены, вопрос «как я это проверю?» задают до кода.

Что почитать дальше