Applying Java Sealed Classes in practice

Applying Java Sealed Classes in practice

In this article, we’ll apply Sealed Classes to improve code readability using a real-world development example.

The article uses Java 21 because this is the first LTS version of Java with a Pattern Matching release. Also, the example uses Spring Boot, but this approach can be used in any similar situation.

A brief description of Sealed Classes and Pattern Matching

Both features are well described in other articles, so I will only give a brief summary here.

Sealed Classes (JEP 409)

A class or interface with a limited number of receivers that are enumerated from the parent. The compiler follows this rule and will issue a compile-time error if it is violated.

Syntax:

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


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

Pattern Matching (JEP 441)

This JEP provides several improvements to switch expressions, but in this article we are interested in variable type checking. Pattern Matching works not only with sealed classes, but only with them and with enums you can cancel the default branch.

Syntax:

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

We use it in practice

Let’s imagine a simple backend implemented using Spring Boot. This backend has an API with a POST / session endpoint that creates a session for the user. This endpoint has three possible response options:

  • 200 OK – if the session was successfully created;

  • 422 Unprocessable Content – if additional information is needed to create a session;

  • 500 Internal Server Error – if a critical error occurred on the backend side.

A standard implementation for Spring will contain the Controller and Service classes (extra details omitted):

@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;
    }
}

The code above will handle the first (200) and last (500) answer choices well. However, this implementation does not consider the option with a 422 response. Possible approaches to solve this problem are:

  • Immediately return a ResponseEntity with the required code from the Service – turns the Controller into an unnecessary layer class;

  • Throwing an exception in Service and processing ControllerAdvice – blurs the business logic by classes, because 422 is a critical error, and the standard response option;

  • Throwing an exception in the Service and handling it in the Controller is not very readable in my opinion.

However, the main problem with the approaches above is poor extensibility, because several more can be added to the 422 response option. In this case, these approaches will be poorly read.

With the help of Sealed Classes, you can make the processing of many options much easier and readable. To begin with, we will create an interface-marker that will indicate the result of the session creation operation:

public sealed interface CreateSessionResult permits SessionInfo, AdditionalInfoRequired {

}

Interface implementations will also be needed, let them be DTOs:

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

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

}

Now let’s change the type of value returned in Service:

@Service
public class SessionService {

    // ...

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

And finally, in the Controller, we will generate the appropriate HTTP response using 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);
        };
    }
}

Thus, the interaction between Controller and Service has become more clear. This approach is useful if there are several possible return options from a method.

What to do if there is no Java 21 in the project

The closest code is available in Java 17. In this version, we only need to change switch to 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("Это исключение никогда не произойдет");
        };
    }
}

The solution is not the most beautiful, but it saves reading and you can be sure that the default branch will never be executed.

Also, in Java 17, Pattern Matching can be enabled by the JVM parameter --enable-preview --source 17.

In Java 11 and below, it will be more difficult to repeat this, because neither Sealed Classes nor an updated switch are there. The principle itself with a marker interface and a limited number of implementations will still work, but reading will be worse.


At the end, I will ask you to share your opinion in the comments: would you use this approach in the production or not? If you have better solutions than the one in the article, I’d love to see them too.

Related posts