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, встроенный в методологию.
- Монолит, модульный монолит или микросервисы — бритва Оккама на архитектурном уровне.