Применяем Java Sealed Classes на практике

f8f393b0803b31a94d1ea900ae6fcf73

В этой статье применим Sealed Classes для улучшения читаемости кода, используя пример из реальной разработки.

В статье используется Java 21 т.к. это первая LTS версия Java с релизным Pattern Matching. Также в примере используется Spring Boot, но этот подход можно использовать в любой похожей ситуации.

Краткое описание Sealed Classes и Pattern Matching

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

Sealed Classes (JEP 409)

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

Синтаксис:

public sealed interface Fruit permits Apple, Orange {
    // Обратите внимание на ключевое слово permits:
    // список разрешенных имплементаций определяется после него.
}


public class Apple implements Fruit {
    // Имплементация определяется как обычно.
}

Pattern Matching (JEP 441)

Этот JEP предоставляет несколько улучшений для switch выражений, но в этой статье нас интересует проверка типа переменной. Pattern Matching работает не только с sealed классами, однако только с ними и с enum’ами можно упразднить default ветку.

Синтаксис:

switch (fruit) {
    case Apple apple -> eat(apple);
    case Orange orange -> give(orange);
    // обратите внимание, что default ветка здесь не нужна
}

Применяем на практике

Представим простой бекенд, реализованный с помощью Spring Boot. В этом бекенде есть API с эндпоинтом POST /session, который создает сессию для юзера. У этого эндпоинта есть три возможных варианта ответа:

  • 200 OK — если сесия была успешно создана;

  • 422 Unprocessable Content — если требуется дополнительная информация для создания сессии;

  • 500 Internal Server Error — если произошла критическая ошибка на стороне бекенда.

Стандартная для Spring имплементация будет содержать классы Controller и Service (лишние детали пропущены):

@RestController
public class SessionApi {

    // ...

    public ResponseEntity createSession(UserInfo userInfo) {
        SessionInfo sessionInfo = sessionService.createSession(userInfo);
        return new ResponseEntity<>(sessionInfo, HttpStatus.OK);
    }
}
@Service
public class SessionService {

    // ...

    public SessionInfo createSession(UserInfo userInfo) {
        // создаем сессию, а в случае критической ошибки выбрасываем исключение,
        // которое будет обработано в @ControllerAdvice
        return sessionInfo;
    }
}

Код выше хорошо справится с первым (200) и последним (500) вариантами ответа. Однако, эта имплементация не учитывает вариант с ответом 422. Возможные подходы для решения этой проблемы:

  • Возвращать из Service сразу ResponseEntity с нужным кодом — превращает Controller в ненужный класс-прослойку;

  • Выбрасывать исключение в Service и обрабатывать в ControllerAdvice — размазывает бизнес-логику по классам, т.к. 422 это не критическая ошибка, а стандартный вариант ответа;

  • Выбрасывать исключение в Service и обрабатывать в Controller — на мой взгляд не очень читабельно.

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

С помощью Sealed Classes можно сделать обработку множества вариантов значительно проще и читаемее. Для начала, создадим интерфейс-маркер, который будет обозначать результат выполнения операции создания сессии:

public sealed interface CreateSessionResult permits SessionInfo, AdditionalInfoRequired {

}

Также потребуются имплементации интерфейса, пускай это будут DTO:

public record SessionInfo(/*поля пропущены*/) implements CreateSessionResult {

}
public record AdditionalInfoRequired(/*поля пропущены*/) implements CreateSessionResult {

}

Теперь сменим тип возвращаемого значения в Service:

@Service
public class SessionService {

    // ...

    public CreateSessionResult createSession(UserInfo userInfo) {
        // в зависимости от ситуации результата может быть двух разных типов
        return someCondition
            ? sessionInfo
            : additionalInfoRequired;
    }
}

И наконец в Controller сформируем подходящий HTTP ответ, используя Pattern Matching:

@RestController
public class SessionApi {

    // ...

    public ResponseEntity createSession(UserInfo userInfo) {
        CreateSessionResult createSessionResult = sessionService.createSession(userInfo);
        return switch (createSessionResult) {
            case SessionInfo sessionInfo -> new ResponseEntity<>(sessionInfo, HttpStatus.OK);
            case AdditionalInfoRequired infoRequired -> new ResponseEntity<>(infoRequired, HttpStatus.UNPROCESSABLE_ENTITY);
        };
    }
}

Таким образом, взаимодействие между Controller и Service стало более понятным. Этот подход будет полезен если предполагается несколько возможных вариантов вовзврата из метода.

Что делать, если Java 21 в проекте нет

Наиболее близкий код можно получить в Java 17. В этой версии нам потребуется лишь поменять switch в Controller:

@RestController
public class SessionApi {

    // ...

    public ResponseEntity createSession(UserInfo userInfo) {
        CreateSessionResult createSessionResult = sessionService.createSession(userInfo);
        return switch (createSessionResult.getClass().getSimpleName()) {
            case "SessionInfo" -> new ResponseEntity<>(createSessionResult, HttpStatus.OK);
            case "AdditionalInfoRequired" -> new ResponseEntity<>(createSessionResult, HttpStatus.UNPROCESSABLE_ENTITY);
            default -> throw new RuntimeException("Это исключение никогда не произойдет");
        };
    }
}

Решение не самое красивое, однако сохраняет читаемость и можно быть уверенным, что ветка default никогда не будет исполнена.

Также в Java 17 можно включить Pattern Matching параметром JVM --enable-preview --source 17.

В Java 11 и ниже повторить подобное будет сложнее, т.к. ни Sealed Classes, ни обновленного switch там нет. Сам принцип с интерфейсом-маркером и ограниченным количеством имплементаций все еще будет работать, однако читаемость будет хуже.

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

© Habrahabr.ru