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

К счастью, в Java есть нечасто используемый модификатор видимости package-private, который очень помогает скрыть нежелательные детали реализации. К сожалению, если количество внутренних классов велико, оно плохо масштабируется, но, к счастью, нам может помочь ArchUnit.

Public vs Private

Разделение private и public позволяет уменьшить связность и получить свободу изменения деталей реализации, не беспокоясь о внесении нежелательных критических изменений.

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

Если бы я этого не сделал, мои руки были бы связаны, так как кто-то мог бы использовать некоторые из моих внутренних классов напрямую.

То же самое относится к любому другому модулю или классу, который должен взаимодействовать с чем-то еще.

Возможности package-private

Для скрытия внутренней структуры пакета можно использовать модификатор package-private и ограничить видимость классов, к которым не следует иметь доступ извне пакета.

Давайте посмотрим на типичную структуру пакета:

В этом варианте, к сожалению, нам нужно оставить все внутренние классы public, потому что видимость package-private работает в пределах одного пакета, а не всей иерархии.

Учитывая это, если мы хотим использовать package-private, нам нужно будет разместить их все в одном пакете:

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

Представляем ArchUnit

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

Например, мы можем воссоздать функциональность иерархического модификатора package-private, ограничив доступ к классам в подпакетах com.pivovarit.movies классами, находящимися во всей иерархии пакетов:

public class ArchitectureTest {

    private static final JavaClasses classes = new ClassFileImporter()
      // ...
      .importPackages("com.pivovarit");

    @Test
    void com_pivovarit_movies_shouldNotExposeInternalClasses() {
        classes().that().resideInAPackage("com.pivovarit.movies.*")
          .should()
          .onlyBeAccessed().byClassesThat()
          .resideInAPackage("com.pivovarit.movies..")
          .check(classes);
    }
}

А теперь, если мы создадим класс вне пакета и будем использовать public API (Rentals), тесты будут зелеными:

package com.pivovarit;

import com.pivovarit.movies.Rentals;

public class Starter {
    public static void main(String[] args) {
        Rentals instance = Rentals.instance();

        boolean rent = instance.rent(42);
    }
}

Но если мы попытаемся получить доступ к MovieDetailsRepository напрямую, мы получим нарушение:

package com.pivovarit;

import com.pivovarit.movies.repository.MovieDetailsRepository;

public class Starter {
    public static void main(String[] args) {
        MovieDetailsRepository movieDetailsRepository
          = new MovieDetailsRepository();
    // java.lang.AssertionError: Architecture Violation
    }
}

Расширяя идею

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

@Test
void shouldHaveZeroDependencies() {
    classes().that().resideInAPackage("com.pivovarit.collectors")
      .should()
      .onlyDependOnClassesThat()
      .resideInAnyPackage("com.pivovarit.collectors", "java..")
      // ...
      .check(classes);
}

… Или обеспечить существование единственного public класса:

@Test
void shouldHaveSingleFacade() {
    classes().that().arePublic()
      .should().haveSimpleName("ParallelCollectors")
      .andShould().haveOnlyPrivateConstructors()
      .andShould().haveModifier(FINAL)
      // ...
      .check(classes);
}

Больше примеров использования ArchUnit можно найти на официальной странице.

Заключение

Отделение внутренних компонентов от public - один из лучших способов повысить удобство сопровождения вашего программного обеспечения. К сожалению, собственные инструменты Java ограничены, но ArchUnit может охватить те случаи, когда Java не может.

Конечно, приведенные выше примеры - это просто основа, на которой вы можете опираться. Реальная жизнь полна более сложных случаев.

Тем не менее, ArchUnit может помочь вам улучшить ваш public API. 

Но ArchUnit не поможет, если вы продолжите добавлять все больше и больше исключений в свои правила.

Примеры из этой статьи можно найти на GitHub.

Примечание переводчика. Дополнительно о применении ArchUnit можно прочитать:

Модульное тестирование архитектуры Spring Boot проекта с помощью ArchUnit

Обнаружение и удаление кода без ссылок с помощью ArchUnit

Внедрение рекомендаций по структуре кода с использованием ArchUnit

Обеспечение границ компонентов чистой архитектуры с помощью Spring Boot и ArchUnit