Принцип разделения интерфейса — SOLID деталях

Четвёртый принцип SOLID — принцип разделения интерфейса (Interface Segregation Principle, ISP), и гласит он следующим образом: «Программные сущности не должны зависеть от методов, которые они не используют». Однако с течением времени данный принцип стал применим не только к интерфейсам, но и к другим аспектам разработки программного обеспечения, что отражено в книге Роберта Мартина «Читая Архитектура»: «Этот принцип призывает разработчиков программного обеспечения избегать зависимости от всего, что не используется». Предлагаю сначала разобраться с интерфейсами, а затем рассмотреть несколько примеров более широкого применения принципа.

Интерфейсы

На мой взгляд формулировка принципа немного не согласуется с его названием, которое намекает на необходимость разделения интерфейсов. По сути принцип разделения интерфейсов — это аналог принципа единственной ответственности для интерфейсов.

Методы специфичные для клиентов

Потребность в разделении крупных интерфейсов на более мелкие может возникать по нескольким причинам. Например, разные методы интерфейса используются разными клиентами. Давайте представим себе следующий интерфейс:

А так же два его «клиента»: UserService:

И MyUserDetailsService:

Как видно из приведённых выше примеров кода, класс UserService зависит от методов findUserByIdupdateUser и deleteUserById, в то время как MyUserDetailsService — только от findUserByUsername. Таким образом у разных методов разные потребители, и появляется смысл в разделении интерфейса UserDao на два разных, каждый из которых будет содержать методы специфичные для конкретного клиента.

Декорирование

Ещё одной причиной для разделения интерфейса UserDao может являться необходимость в декорировании методов интерфейса. Давайте представим себе, что нам необходимо добавить кэширование при помощи декорирования, тогда в проекте появится класс-декоратор CachingUserDao:

Как можно увидеть в примере кода CachingUserDao, в методах findUserByUsername и findUsers отсутствует какая-либо дополнительная логика, вызов просто передаётся декорируемому объекту, следовательно, эти методы можно выделить в отдельный интерфейс.

Архитектура

Так же на необходимость в разделении интерфейса могут указывать архитектурные решения, принятые в проекте. Так, если в проекте применяется архитектурный подход, разделяющий операции на команды и запросы (Command and Query Responsibility Segregation; CQRS), то методы для получения информации (findUserByIdfindUserByUsername и findUsers) могут взаимодействовать с одной базой данных, а методы для сохранения информации — с другой.

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

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

Функциональные интерфейсы

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

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

Описанный выше интерфейс UserDao в таком случае будет разбит на следующие функциональные интерфейсы:

Антипаттерн «Суп/каша из интерфейсов»

Разделение интерфейса имеет и побочные эффекты, одним из которых является увеличение количества зависимостей у классов-клиентов. Допустим, интерфейс UserDao был разделён на описанные выше функциональные интерфейсы, тогда класс UserService примет следующий вид:

Теперь у класса UserService вместо одной зависимости UserDao их целых три: FindUserByIdSpiUpdateUserSpi и DeleteUserByIdSpi. Некоторые разработчики с целью снижения количества зависимостей в классах-клиентах создают интерфейсы, которые объединяют несколько других, полученных в процессе разделения. Следующий пример кода демонстрирует такой интерфейс:

Да, это снижает количество зависимостей в классе UserService, но снижает гибкость кода и нивелирует пользу от разделения интерфейса.

Зависимости

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

Ещё один пример нарушения принципа разделения интерфейса — наличие в проекте неиспользуемой функциональности. Когда вы создаёте новый веб-проект на Spring Boot, в нём кроме всего прочего есть всё для поддержки веб-сокетов. Да, вы можете работать с ними прямо из коробки, но когда в последний раз вам нужны были веб-сокеты в REST API?

То же самое касается и неиспользуемых библиотек.

Выводы

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