В этой статье применим 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<? extends CreateSessionResult> 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<? extends CreateSessionResult> 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 там нет. Сам принцип с интерфейсом-маркером и ограниченным количеством имплементаций все еще будет работать, однако читаемость будет хуже.


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

Комментарии (21)


  1. BugM
    06.01.2024 22:48

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

    Погрепайте свои продакшен логи на предмет наличия в них исключений которые никогда не произойдут. Узнаете много интересного.

    На 17 JDK оно тоже нормально пишется без ветки по умолчанию. Скастите в энум, например. Тоже упадет с исключением, но хотя бы в более понятном месте и с более понятной ошибкой.


    1. avxx Автор
      06.01.2024 22:48
      +1

      У нас в код со сравнением имен классов не под сильной нагрузкой, не нашел ни одного лога с выполненной веткой default. Я честно не могу представить ситуацию, в которой она выполнится - поделитесь пожалуйста, если встречали такое.

      С enum'ами вариант неплохой, но упрощая switch усложнится весь остальной код, т.к. из сервиса мы хотим получить уже готовую модель для сериалиизации.


  1. dididididi
    06.01.2024 22:48
    -6

    У тебя и в сервисе ифчик(тернарный оператор) и в контролёре тот же ифчик(свич).

    В сервисе типа: if(result==null) return "error", а потом в контроллера if ("error") Return errorResponce;

    Ну добавь ещё класс напиши там if(errorResponce) return 0; полимофизмом только ифчик сделай, чтоб по солиду. Ещё три класса будет, ништяк же.

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

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


    1. dididididi
      06.01.2024 22:48
      +1

      Не Ломбок, а swagger


    1. avxx Автор
      06.01.2024 22:48

      В сервисе тернарный оператор поставил для простоты (в статье приписка, что детали пропущены), в реальном коде все может быть сложнее.
      Идея как раз в том, чтобы возвращать не что попало, а ограниченный набор классов - такую гарантию дает компилятор. Буду рад посмотреть твой вариант обработки описанного кейса.
      Swagger генерит то, что в аннотациях указано, поэтому думаю справится. Не могу сказать точно, т.к. мы не генерим в том конкретном проекте.


      1. dididididi
        06.01.2024 22:48
        +2

        Представьте приходит программист на ваш проект, ему надо срочно описание ошибк починить он c ctrl-shift-f @controllerAdviii ищет нужный перехватчик и правит, а не разбирается в вашем велике с 21 жавой.

        Вы пишете учебный пример, он должен быть идеален. А у большинства мысль, ну это @controllerAdvice прямее сделать можно, а ту наговнокожено ради 21 жавы только.

        Сваггер скорей всего поломали, апи спроектировано криво, стандартные паттерны выкинули.

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

        Зы. Мне пофиг как вы напишете if else свичом, тернарником, полиморфизмом или будете в цикле перебирать, пока не совпадет, прикол в том что вы сделали один ифчик дважды.


        1. brutfooorcer
          06.01.2024 22:48
          +1

          В сваггере есть @ApiResponse, там можно определить тело для конкретного ответа.

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

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


          1. dididididi
            06.01.2024 22:48

            Я не сомневаюсь, что сваггер можно подлатать и переопределить. Просто задача, чтоб работалао дефолтно из игроки, а не сломать, а потом героически чинить.

            Там есть два типа ответа? Хошь такую дто возвращаю, хошь другую?


        1. avxx Автор
          06.01.2024 22:48

          Сваггер скорей всего поломали, апи спроектировано криво, стандартные паттерны выкинули

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

          Во-первых, как я уже отвечал ниже и как написано в статье, подход с sealed классами не предполагает замену @ControllerAdvise - обрабатывать исключения все еще лучше в нем. Описанный в статье подход стоит использовать, когда возможны два и более валидных юзер флоу, т.е. это не ошибка, просто вариантов куда отправить юзера дальше больше чем один. В статье я описал другие варианты решения подобного кейса и также описал, чем они мне не нравятся.

          Во-вторых, когда разработчик приходит, то он обычно имеет время разобраться с код-стайлом и подходами, которые использует команда - онбординг, в общем. У нас он есть, также как и код-ревью, на первые задачи новоприбывшего команда уделяет особое внимание. Если у вас разработчик должен с места в карьер прыгать - что ж, могу лишь посочувствовать. Ну а если разработчик игнорирует "велик с 21 жавой", решение даже первой задачи у него просто поиск по через cmd-shif-f без просмотра хотя бы входной точки (контроллера) - то я бы не хотел с таким разработчиком работать.

          Наконец, я не понимаю, почему тебя так тригернул тернарный оператор + switch из примера, они в разных классах. Может, ты еще приходишь в ярость, когда метод возвращает Optional? Там ведь тоже (о ужас) при создании Optional может быть if, и при его обработке тоже.


          1. dididididi
            06.01.2024 22:48

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

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

            Те не кажется, что схема усложнена? И можно оставить одну бабушку, которая в зависимости от пола направляет человека в нужный туалет?)

            Так вот первая бабушка тернарник, вторая свич. И если у тебя появится транссексуалы, те придется выжать бабушке ещё один жёлтый билетик и второй бабушке дать инструкции чо делать. А потом инвалида, собачки и понеслось.


            1. avxx Автор
              06.01.2024 22:48

              Аналогия хорошая, но в вакууме. Можешь, пожалуйста, предоставить код, который решает кейс из статьи без "двойного if"? У тебя скорее всего будет код, который из сервиса вернет ResponseEntity, имхо это плохая практика.


              1. dididididi
                06.01.2024 22:48

                Это наистандартнейшая задача.

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

                Ты всерьез, спрашиваешь меня как это сделать?

                Через контролерэдвайс и не трахать мозг.

                Добавить в responce объект errors и наполнять его.


            1. BinDecHex
              06.01.2024 22:48

              Разрешите встряну. Зачастую бывает, что между первой и второй бабушками изначальный мальчик может стать девочкой(осуждаем) и выполнить определенную бизнес-логику. Второй бабушке неизвестно, что девочка была мальчиком, но зато есть синий билетик.


          1. dididididi
            06.01.2024 22:48

            Про два валидных юзер флоу в рест запросе - это сесть и плакать. Ты про круд слышал чо нить?)

            Ну типа послали тебе ПОСТ создать сессию а ты либо сессию вернул, либо юзера, либо ошибку, либо фотку голой Пенелопы Круз.

            Какать в коде оправдываясь тем, что есть онбординг, это прям выкидывать за Шкирняк сразу. А если в коде миллионы строк, и на каждом шагу ваша гениальность ушки показывает?


            1. avxx Автор
              06.01.2024 22:48
              +5

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

              Добавляю контекста: POST возвращает либо сессию, либо требование пройти двухфакторку, там сложная бизнес-логика которая это делает. Оба варианта прописаны в API спецификации, контракт прозрачный. Как (и главное зачем) это API переделывать?

              За "шкирняк" надо выкидывать людей, которые не разобравшись обзывают чужие решения говнокодом. Если бы все на свете можно было сделать через if, не теряя читаемости и расширяемости, то все бы так и делали. Но это не так, и например кейс из статьи был вереницей тройных вложенных if'ов в куче классов, с перемешенной логикой и возвратом из контроллера и сервиса ResponseEntity<Object>. Зато без "великов ради джавы 21".

              Напоследок добавлю еще раз, что я не призываю срочно везде повтыкать switch'ей с обвязкой из интерфейсов. Я предложил подход для случаев, когда выносить в ControllerAdvice не подходит, а возвращать из сервиса сформированный ответ не позволяет разделение ответственности между контроллером и сервисом. Не нравится или не подходит под твой кейс - не используй, зачем оскорблять автора?)


              1. dididididi
                06.01.2024 22:48

                Я думал это криво придуманный учебный пример, что простительно. А это из прода?


  1. SimSonic
    06.01.2024 22:48
    +1

    Мне недавно sealed классы зашли с джексоном и его JavaTypeName. Правда вручную пришлось зарегать наследников при построении маппера, но это выглядело лучше, чем дублирование кода в permits и аннотациях. Есть открытый issue, в котором даже посоветовали готовый сторонний модуль, который автоматизирует регистрацию сабтипов. Под рукой нет ссылок, извините.


  1. softaria
    06.01.2024 22:48
    +1

    В более ранних версиях java можно применить visitor. Несколько громоздко, но нет проблемы с default и с «я забыл добавить обработку нового возвращаемого класса»


  1. panzerfaust
    06.01.2024 22:48
    +2

    Дальше у вас апи вырастает до 500 эндпоинтов. В каждом будете писать switch и придумывать набор силд-классов по все возможные исходы? У вас очень быстро появится желание навести порядок в этом зоопарке и не повторяться. И выбор будет невелик. Или придумаете монадоподобный контейнер с обработчиком или вернетесь к идее каталога исключений и ControlerAdvice. В общем-то оба подхода про одно и то же: делегировать разбор ошибок куда-то под коврик, чтобы под ногами не путался. "Размазывание бизнес-логики" здесь - скорее проблема какой-то конкретной архитектуры, а не проблема этого паттерна в целом.


    1. avxx Автор
      06.01.2024 22:48

      Я не думаю, что каждый эндпоинт будет нуждаться в таком. В API, из которого я взял пример, достаточно много эндпоинтов, и в большинстве случаев все решается через ControllerAdvice (в статье например так решен вариант с 500). Однако бывают случаи, когда предполагается два валидных ответа юзеру, по сути 422 в примере это не ошибка, а нормальный юзер флоу. В таких случаях, на мой взгляд, возврат sealed интерфейса подойдет.
      Можете пожалуйста раскрыть идею с "монадоподбным контейнером с обработчиком"?


  1. LaRN
    06.01.2024 22:48

    Тут наверное можно поиметь проблемы со spring, если попробовать заижектить объект в поле или параметр у которого тип это sealed интерфейс. Оно может при исполнении рвануть и не сразу будет понятно от чего.