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

Почему так тяжело менять этот код? Почему один класс правят сразу несколько команд? Почему замена библиотеки ломает половину системы? На эти три боли отвечают пять принципов проектирования классов, сложенных в аббревиатуру SOLID.

Три из них — SRP, ISP и DIP — сформулировал Роберт Мартин, открытость-закрытость — Бертран Мейер (1988), подстановку — Барбара Лисков (1987; строгую формулировку она опубликовала вместе с Жаннетт Винг в 1994 году); в аббревиатуру их сложил Майкл Фезерс. Принципы часто объясняют на примерах с геометрическими фигурами, и они кажутся теорией ради теории; здесь каждый идёт от боли, которую снимает.

новый тип клиента: PARTNER DiscountCalculator case VIP → 10% case EMPLOYEE → 20% case PARTNER → 15% правим существующий класс перепроверять придётся весь DiscountCalculator DiscountPolicy Vip Employee Partner добавляем новый класс существующие не тронуты

Требование приходит одно, а принять его можно двумя способами: дописать ветку внутрь существующего класса — и перепроверять его целиком; или добавить новый класс за интерфейсом — и не трогать старые. Это общая механика OCP и DIP.

SRP — одна ответственностьспросят на собеседовании

У класса должна быть одна причина для изменения.

Представьте сервис, который со временем вырос в «свалку»:

public class OrderService {
    public OrderDto createOrder(CreateOrderRequest request) { /* 120 строк */ }
    public byte[] exportOrders(OrderFilter filter) { /* 90 строк */ }
    public void recalculateStatistics() { /* 70 строк */ }
}

Когда меняется правило создания заказа — правим этот класс. Когда меняется формат экспорта — снова он. У класса несколько несвязанных причин для изменения, и любая правка рискует задеть остальное.

OrderService отдел продаж правила заказа бухгалтерия формат экспорта маркетинг статистика

Три заказчика тянут правки в один класс: причин для изменения три, и любая из них задевает чужую часть.

SRP говорит: у класса должна быть одна причина для изменения. Позже Мартин уточнил, что причина — это не «действие», а человек: модуль отвечает перед одним и только одним заказчиком. Правила экспорта просит бухгалтерия, правила создания заказа — отдел продаж, формулу статистики — маркетинг: три заказчика, три причины, три класса.

@RequiredArgsConstructor
public class CreateOrderHandler {

    private final OrderRepository orders;
    private final PricingPolicy pricing;

    public OrderDto handle(CreateOrderCommand cmd) {
        var order = Order.create(cmd.customerId(), cmd.lines(), pricing);
        orders.save(order);
        return OrderDto.from(order);
    }
}

Этот класс меняется только когда меняется правило создания заказа. Отправка письма покупателю — другая ответственность и другой обработчик.

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

живой пример

public class SrpDemo {

    record Order(String id, int total) {}

    interface PricingPolicy { int apply(int total); }
    interface ExportFormat { String render(Order order); }
    interface StatsSink { void record(Order order); }

    static class OrderEverything {
        private final PricingPolicy pricing;
        private final ExportFormat format;
        private final StatsSink stats;

        OrderEverything(PricingPolicy pricing, ExportFormat format, StatsSink stats) {
            this.pricing = pricing;
            this.format = format;
            this.stats = stats;
        }

        Order create(String id, int total) {
            Order order = new Order(id, pricing.apply(total));
            stats.record(order);
            return order;
        }
    }

    static class CreateOrderHandler {
        private final PricingPolicy pricing;

        CreateOrderHandler(PricingPolicy pricing) {
            this.pricing = pricing;
        }

        Order handle(String id, int total) {
            return new Order(id, pricing.apply(total));
        }
    }

    public static void main(String[] args) {
        PricingPolicy tenPercentOff = total -> total - total / 10;
        System.out.println("свалке нужно заглушек: "
            + OrderEverything.class.getDeclaredConstructors()[0].getParameterCount());
        System.out.println("обработчику создания: "
            + CreateOrderHandler.class.getDeclaredConstructors()[0].getParameterCount());
        System.out.println(new OrderEverything(tenPercentOff, o -> "csv", o -> {}).create("A-1", 1000));
        System.out.println(new CreateOrderHandler(tenPercentOff).handle("A-2", 1000));
    }
}
Запустить

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

Печатает 3 и 1: чтобы создать один заказ в классе-свалке, надо принести экспорт и статистику, которые к созданию отношения не имеют. Эту цену платит каждый, кто пишет тест.

Хороший сигнал нарушения SRP — когда за союзом «и» в устном описании стоят разные заказчики: «он создаёт заказ и отправляет письмо и пересчитывает статистику». А вот «проверил, сохранил и вернул ответ» — это один сценарий одного заказчика, делить тут нечего.

OCP — открыт для расширения, закрыт для измененияспросят на собеседовании

Появился новый тип клиента, и снова правим тот же метод расчёта скидки, и снова перепроверяем весь класс, потому что тронули общий код. Типичная картина — switch или цепочка if, в которую дописывают ветку при каждом новом варианте. Принцип открытости-закрытости запрещает ровно это:

Поведение системы расширяется добавлением кода, а не правкой существующего.

Вот такой switch:

// так не надо
public BigDecimal discount(Order order) {
    return switch (order.customer().type()) {
        case VIP      -> order.total().multiply(new BigDecimal("0.10"));
        case EMPLOYEE -> order.total().multiply(new BigDecimal("0.20"));
        case REGULAR  -> BigDecimal.ZERO;
    };
}

Новый тип клиента — правка этого метода. И всех остальных switch по тому же признаку в других местах кода.

Решение — объявить точку расширения (интерфейс) и добавлять новое поведение новым классом, не трогая существующий:

живой пример

import java.math.BigDecimal;
import java.util.List;

public class DiscountDemo {

    interface DiscountPolicy {
        boolean supports(String customerType);
        BigDecimal discount(BigDecimal total);
    }

    record VipPolicy() implements DiscountPolicy {
        public boolean supports(String customerType) { return "VIP".equals(customerType); }
        public BigDecimal discount(BigDecimal total) { return total.multiply(new BigDecimal("0.10")); }
    }

    // новый тип клиента — новый класс, старые не тронуты
    record PartnerPolicy() implements DiscountPolicy {
        public boolean supports(String customerType) { return "PARTNER".equals(customerType); }
        public BigDecimal discount(BigDecimal total) { return total.multiply(new BigDecimal("0.15")); }
    }

    static BigDecimal discount(List<DiscountPolicy> policies, String customerType, BigDecimal total) {
        return policies.stream()
                .filter(p -> p.supports(customerType))
                .findFirst()
                .map(p -> p.discount(total))
                .orElse(BigDecimal.ZERO);
    }

    public static void main(String[] args) {
        List<DiscountPolicy> policies = List.of(new VipPolicy(), new PartnerPolicy());
        BigDecimal total = new BigDecimal("1000");
        System.out.println("VIP:     " + discount(policies, "VIP", total));
        System.out.println("PARTNER: " + discount(policies, "PARTNER", total));
        System.out.println("REGULAR: " + discount(policies, "REGULAR", total));
    }
}
Запустить

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

Печатает 100.00, 150.00 и 0: партнёрская скидка появилась новым классом, а метод discount не изменился.

Оговорка: OCP не значит «плодить интерфейсы на всякий случай». Если вариантов заведомо два и новых не ожидается — switch честнее.

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

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

LSP — принцип подстановки Лисковспросят на собеседовании

Класс называет себя репозиторием, наследует готовую реализацию, а неудобный delete объявляет неподдерживаемым и бросает исключение: код, который работал с базовым классом, со «специализированным» ломается. Так выглядит наследование ради повторного использования кода, и принцип подстановки его запрещает:

Реализацию можно заменить на любую другую реализацию того же контракта — и вызывающий код не заметит разницы.

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

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

живой пример

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class LspDemo {

    interface ProductRepository {
        Optional<String> findById(int id);
        void delete(int id);
    }

    static class MapRepository implements ProductRepository {
        private final Map<Integer, String> rows = new HashMap<>(Map.of(1, "кофемолка"));
        public Optional<String> findById(int id) { return Optional.ofNullable(rows.get(id)); }
        public void delete(int id) { rows.remove(id); }
    }

    // так надо: композиция — кеш делегирует и чистит себя
    static class CachingRepository implements ProductRepository {
        private final Map<Integer, String> cache = new HashMap<>();
        private final ProductRepository delegate;
        CachingRepository(ProductRepository delegate) { this.delegate = delegate; }
        public Optional<String> findById(int id) { return Optional.ofNullable(cache.computeIfAbsent(id, key -> delegate.findById(key).orElse(null))); }
        public void delete(int id) { delegate.delete(id); cache.remove(id); }
    }

    // так не надо: наследник сузил контракт
    static class ReadOnlyRepository extends MapRepository {
        @Override public void delete(int id) { throw new UnsupportedOperationException("только чтение"); }
    }

    static void removeAndReport(String name, ProductRepository repo) {
        try {
            repo.delete(1);
            System.out.println(name + ": после удаления " + repo.findById(1));
        } catch (UnsupportedOperationException e) {
            System.out.println(name + ": контракт сломан — " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        removeAndReport("композиция", new CachingRepository(new MapRepository()));
        removeAndReport("наследник", new ReadOnlyRepository());
    }
}
Запустить

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

Первая строка — Optional.empty: товар ушёл и из хранилища, и из кеша. Вторая — сообщение о сломанном контракте: вызывающий код тот же, а падает только вторая реализация. Так LSP и ловится — не в классе-нарушителе, а у того, кто им пользуется.

removeAndReport CachingRepository удалил, вернул пусто ReadOnlyRepository бросил исключение

Вызывающий код не менялся, а результат разный: одна реализация выполняет контракт, вторая бросает исключение.

Обёртка лечит первый случай, но не второй: репозиторий «только на чтение» точно так же обязан что-то сделать с delete, хоть наследником, хоть обёрткой. Если удаление здесь не нужно вовсе — контракт надо не сужать, а объявлять отдельный, узкий: ровно об этом следующий принцип.

Правило-подсказка: UnsupportedOperationException в переопределённом методе — повод присмотреться, но не приговор. Сам JDK бросает его штатно: List.of(1, 2).add(3), Arrays.asList(1, 2).add(3), Collections.unmodifiableList(list).remove(0) — все три падают, и LSP цел, потому что Collection прямо объявляет add и remove необязательными операциями. Ломает контракт не исключение само по себе, а исключение, которого нет в контракте базового типа — того, на который рассчитывал вызывающий.

ISP — разделение интерфейсовспросят на собеседовании

Интерфейсы имеют свойство разрастаться: к save и findById со временем добавляются findForListing, findForExport, archiveOlderThan, и сервис оформления заказа, которому нужны два метода, зависит от семи и меняется при каждой правке экспорта. Принцип разделения интерфейсов:

Клиент не должен зависеть от методов, которые он не использует.

Вот такой интерфейс:

public interface OrderStorage {
    void save(Order order);
    Optional<Order> findById(OrderId id);
    Page<OrderListRow> findForListing(OrderFilter filter, Pageable pageable);
    List<OrderExportRow> findForExport(LocalDate from, LocalDate to);
    void archiveOlderThan(LocalDate date);
}

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

OrderStorage: 5 методов обработчик команды нужны save и findById экспорт отчётов нужны два других

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

Решение — разрезать по потребителям, не по таблице:

// для команд — только то, что нужно
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
}

// для чтения и отчётов — отдельно
public interface OrderViewRepository {
    Page<OrderListRow> findForListing(OrderFilter filter, Pageable pageable);
    List<OrderExportRow> findForExport(LocalDate from, LocalDate to);
}

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

Почему это вообще что-то даёт, если реализация всё равно одна? Потому что цену зависимости платит не реализация, а потребитель, и платит трижды.

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

живой пример

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class IspDemo {

    record Order(String id, int total) {}

    interface OrderStorage {
        void save(Order order);
        Optional<Order> findById(String id);
        String findForListing();
        String findForExport();
        void archiveOlderThan(int days);
    }

    interface OrderRepository {
        void save(Order order);
        Optional<Order> findById(String id);
    }

    static class CreateOrderHandler {
        private final OrderRepository orders;

        CreateOrderHandler(OrderRepository orders) {
            this.orders = orders;
        }

        Order handle(String id, int total) {
            Order order = new Order(id, total);
            orders.save(order);
            return order;
        }
    }

    public static void main(String[] args) {
        Map<String, Order> rows = new HashMap<>();
        OrderRepository stub = new OrderRepository() {
            public void save(Order order) { rows.put(order.id(), order); }
            public Optional<Order> findById(String id) { return Optional.ofNullable(rows.get(id)); }
        };
        System.out.println("методов в широком интерфейсе: " + OrderStorage.class.getDeclaredMethods().length);
        System.out.println("методов в узком: " + OrderRepository.class.getDeclaredMethods().length);
        System.out.println(new CreateOrderHandler(stub).handle("A-1", 1000));
        System.out.println("в базе: " + stub.findById("A-1"));
    }
}
Запустить

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

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

DIP — инверсия зависимостейспросят на собеседовании

Order шлёт письмо через SMTP-клиент напрямую: чтобы протестировать оформление заказа, нужен почтовый сервер, а смена транспорта на очередь трогает домен. Направление зависимости здесь перевёрнуто относительно того, каким оно должно быть: не домен должен зависеть от инструмента, а инструмент от домена, отсюда и слово «инверсия».

Модули верхнего уровня не зависят от модулей нижнего уровня. Оба зависят от абстракций.

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

// так не надо
public class Order {
    public void cancel(JavaMailSender mailSender) {
        this.status = Status.CANCELLED;
        mailSender.send(customer.email(), "Заказ отменён");
    }
}

Доменная модель зависит от JavaMailSender — конкретной инфраструктурной детали. Смена транспорта на push-уведомления означает правку Order, а проверить отмену без реального SMTP тяжело.

Инверсия: домен объявляет что ему нужно (интерфейс), а инфраструктура решает как это реализовать:

// интерфейс объявлен рядом с доменом, говорит на языке домена
public interface NotificationPort {
    void orderCancelled(Order order);
}

// реализация — в инфраструктурном слое
@RequiredArgsConstructor
public class SmtpNotificationAdapter implements NotificationPort {

    private final JavaMailSender mailSender;

    @Override
    public void orderCancelled(Order order) {
        mailSender.send(buildMessage(order));
    }
}

Order больше ничего не знает про SMTP. В тесте NotificationPort легко подменяется заглушкой. Смена транспорта — новый адаптер, домен не трогается.

Зависимость идёт от SmtpNotificationAdapter к NotificationPort, который живёт рядом с доменом, а не наоборот: это и есть инверсия, вызов идёт в одну сторону, зависимость в другую.

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

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

Инверсия управления и внедрение зависимостей — про то, кто создаёт объекты и подставляет их в поля: не класс вызывает new SmtpNotificationAdapter(...), а контейнер приносит готовую реализацию снаружи. Это механика времени выполнения, и разобрана она отдельно — в статье внедрение зависимостей и жизненный цикл бина.

Разница видна на двух ошибках. Можно внедрять через конструктор (внедрение есть), но объявить тип поля конкретным классом SmtpNotificationAdapter — DIP нарушен, домен снова знает про почту. И наоборот: можно вообще не иметь контейнера, собирать объекты руками в main и полностью соблюдать DIP. Контейнер — удобный способ соблюсти принцип, но не сам принцип.

Когда принципы конфликтуют друг с другомспросят на собеседовании

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

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

ISP против простоты. На сервисе из десяти классов два интерфейса вместо одного — это два файла, которые надо открыть, чтобы понять одно. Узкие интерфейсы окупаются там, где у зависимости несколько разных потребителей и они меняются вразнобой; там, где потребитель один, OrderRepository с пятью методами честнее двух интерфейсов «на будущее».

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

LSP против переиспользования. Наследование даёт даровой код, и соблазн «дописать один метод и переопределить второй» силён именно поэтому. Принцип подстановки этот путь закрывает: если наследник не может выполнить весь контракт родителя, переиспользовать надо композицией, даже когда это на двадцать строк длиннее.

DIP против прямолинейности. Каждый порт — это ещё один переход при чтении: щёлкнул по методу, попал в интерфейс, ищешь реализацию. Для зависимости, которая заведомо одна и вряд ли изменится (скажем, генератор идентификаторов), прямой вызов читается лучше. Инверсия окупается там, где реализация действительно меняется или где её надо подменить в тесте.

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

Чего SOLID не покрываетспросят на собеседовании

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

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

Про данные в SOLID нет ничего. Схема таблиц, индексы, форма хранимого документа, миграции — отдельная область, где ошибки живут годами и стоят дороже любого класса.

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

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

Коротко

  • SRP: у класса одна причина для изменения, а причина — это заказчик. Если за «и то, и это» стоят разные заказчики — класс пора разделить.
  • OCP: новое поведение добавляется новым классом, не правкой существующего. Интерфейс как точка расширения.
  • LSP: подкласс или реализация заменяют оригинал без сюрпризов: предусловия не усиливаются, постусловия не ослабляются, инварианты сохраняются. UnsupportedOperationException в переопределённом методе — сигнал проверить, объявлен ли отказ в контракте базового типа.
  • ISP: интерфейс содержит только то, что нужно конкретному потребителю. Большой интерфейс режется на несколько узких.
  • DIP: бизнес-логика зависит от интерфейсов, а не от конкретных классов. Направление зависимостей — к домену, не от него.
  • Принципы работают вместе: класс с одной ответственностью (SRP) зависит от узкого интерфейса (ISP), объявленного в домене (DIP), реализации которого взаимозаменяемы (LSP), а новые варианты поведения добавляются новыми классами (OCP).
  • Принципы конфликтуют: SRP спорит с одной транзакцией, ISP — с простотой маленького сервиса, OCP — с YAGNI. Побеждает тот, чью боль вы уже чувствуете.
  • Рамка: SOLID — про устройство класса. Границы модулей, схему данных и многопоточность он не покрывает вовсе.

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