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

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

DRY — Don't Repeat Yourself

Каждая единица знания должна иметь одно авторитетное представление в системе.

Ключевое слово — знание, не текст. DRY нарушен, когда бизнес-правило «заказ дороже 10 000 ₽ — бесплатная доставка» записано в трёх местах: в Handler-е, в SQL-отчёте и в JavaScript-валидации. Поменяется порог — три правки, и одну забудут.

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-параметры. Эмпирика — правило трёх: терпите дублирование до третьего повторения, по третьему станет видно, что в нём общее знание, а что совпадение.

В Spring-стеке против механического дублирования работают инструменты, а не копипаст-дисциплина: @RestControllerAdvice — одна обработка ошибок на сервис, MapStruct — маппинг без ручных присваиваний, Lombok — без рукописных конструкторов. Отдельная льгота у тестов: читаемость сценария важнее дедупликации, повторяющийся arrange-блок в трёх тестах лучше хитрой тест-иерархии.

KISS — Keep It Simple

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

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

Spring-иллюстрация — выбор стека: WebFlux добавляет реактивные типы, чужой стиль отладки и context propagation — и оправдан только когда профиль нагрузки этого требует; с виртуальными потоками Java 21 обычный MVC закрывает почти всё. То же с инфраструктурой: очередь задач в PostgreSQL-таблице со SKIP LOCKED проще Kafka — если событий тысяча в час, Kafka «на вырост» означает кластер, мониторинг и команду, которая всё это знает.

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

YAGNI — You Aren't Gonna Need It

Не строй функциональность до того, как она реально понадобилась.

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

В UCP YAGNI встроен в методологию уровнями зрелости: MVP не строят на Hexagonal — уровень 1 со слоёной архитектурой честно покрывает прототип, и переход на уровень выше делается тогда, когда сложность домена его оправдала. Архитектура «на вырост» — то же преждевременное обобщение, что и generic-фабрика, только дороже на порядок.

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

Separation of Concerns

Разные аспекты системы — в разных модулях: смешение аспектов делает каждый из них неизменяемым.

Самый старый принцип списка (Дейкстра, 1974) и зонтик над SRP: SRP — про один класс, SoC — про систему. Spring построен на SoC буквально: транзакции, безопасность, кеширование, метрики отделены от бизнес-логики аннотациями и AOP; HTTP отделён от обработки контроллером и конвертерами; конфигурация — от кода профилями и @ConfigurationProperties.

В прикладном коде SoC — это слои UCP: транспорт (контроллер, DTO) ничего не знает про домен, домен — про persistence, и каждый аспект меняется без оглядки на остальные. Признак смешения — import через границу: jakarta.servlet.* в Handler-е, jOOQ-типы в агрегате.

Law of Demeter

Разговаривай только с непосредственными друзьями: метод вызывает методы своих полей, параметров и локальных объектов — но не «друзей друзей».

Классическое нарушение — цепочка через внутренности чужих объектов:

var city = order.getCustomer().getAddress().getCity();

Этот код знает структуру трёх классов; изменение любого ломает всех, кто так написал. Лекарство — Information Expert из GRASP: спросить ближайшего:

var city = order.shippingCity();

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

Composition over Inheritance

Переиспользуй поведение через композицию объектов, а не наследование классов.

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

Сам Spring прошёл этот путь публично: WebSecurityConfigurerAdapter — базовый класс, от которого наследовали security-конфигурацию, — устарел в 5.7 и удалён в 6.0. Замена — композиция: бин SecurityFilterChain, собираемый lambda-DSL:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(authorize -> authorize
            .requestMatchers("/actuator/health").permitAll()
            .anyRequest().authenticated())
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
        .build();
}

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

Fail Fast

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

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

Три рубежа в Spring-сервисе. Старт приложения: @Validated на @ConfigurationProperties валит сервис при некорректном конфиге — упасть при деплое лучше, чем в пятницу ночью на проде. Граница запроса: Bean Validation на DTO отбрасывает мусор до бизнес-логики. Конструктор домена: value object не существует в некорректном состоянии:

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

Антипод — «оборонительное» глотание: catch (Exception e) { log.error(...); return null; } превращает раннюю ошибку в позднюю. Как строить обработку ошибок без глотания — Error Handling Style Guide.

Principle of Least Astonishment

Код должен делать то, что ожидает читатель; удивление — признак дефекта дизайна.

Метод getOrder(), который заодно меняет статус; GET-эндпоинт с побочным эффектом; equals, сравнивающий по ссылке у value-типа — всё это работает, но врёт читателю. Цена — не эстетика: код, которому нельзя верить по сигнатуре, приходится читать целиком, каждому, каждый раз.

Spring-конвенции — прикладная форма принципа: query-методы Spring Data ведут себя так, как читаются (findByStatusAndCreatedAtAfter); @Transactional(readOnly = true) сообщает и читателю, и базе, что записи не будет; REST-семантика обещает, что GET безопасен, а PUT идемпотентен — и нарушение этого обещания ломает кеши и retry-политики, которые на него полагаются.

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

Бритва Оккама

Не плоди сущности сверх необходимого: из объяснений и конструкций равной силы верна простейшая.

В проектировании бритва работает на уровне выше KISS: KISS — про форму кода, Оккам — про состав системы. Каждая сущность — сервис, брокер, база, библиотека, слой, паттерн — должна оправдывать своё существование. Второй микросервис при одной команде и одном домене, Redis при ста запросах в секунду, отдельный «common»-модуль ради трёх утилит — сущности, размноженные сверх необходимого.

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

Девять принципов одной таблицей

ПринципПро чтоТиповое нарушение
DRYОдно знание — одно местоБизнес-правило в трёх слоях; или наоборот — склейка случайно похожего
KISSПростейшее работающее решениеWebFlux без реактивной нагрузки, generic-фабрика вместо switch
YAGNIНе строить до потребностиHexagonal на MVP, конфигурируемость без второго потребителя
SoCАспекты — раздельноТранзакции и HTTP-коды в бизнес-логике
Law of DemeterТолько с друзьямиorder.getCustomer().getAddress().getCity()
Composition over InheritanceДелегировать, не наследоватьБазовый AbstractService со «общим» поведением
Fail FastОшибка — рано и близко к причинеcatch (Exception e) { return null; }
Least AstonishmentКод делает, что обещаетGET с побочным эффектом, мутирующий геттер
Бритва ОккамаМеньше сущностейВторой сервис, брокер и кеш «на всякий случай»

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

  • SOLID на примерах Spring — те же идеи на уровне устройства класса.
  • GRASP на примерах Spring — кому отдать ответственность.
  • Уровни зрелости UCP — YAGNI, встроенный в методологию.
  • Монолит, модульный монолит или микросервисы — бритва Оккама на архитектурном уровне.