Say Goodbye to NotFoundException: Rich Error Handling with Sealed Classes

Java exceptions are great for truly exceptional conditions (things like I/O failures), but using them for expected outcomes — “user not found”, “invalid password”, “email already taken” — is expensive and semantically wrong.

Since Java 17+, sealed classes + records + pattern matching give us something very close to Rust’s Result<T, E> or Kotlin’s sealed classes — with zero runtime overhead and full compile-time exhaustiveness checking.

Here’s how to do it properly in modern Java.

The old way: Please stop using it!

public User getUser(String id) throws UserNotFoundException { … }

try {
    User user = service.getUser(id);
} catch (UserNotFoundException expectedButStillInCatch) { … }

The modern way:

First of all, we can create a reusable interface which emulates Rust’s great Result type:

public sealed interface Result<S, F>
        permits Result.Ok, Result.Err {

    record Ok<S, F>(S value) implements Result<S, F> {}
    record Err<S, F>(F error) implements Result<S, F> {}

    // Factory methods
    static <S, F> Result<S, F> ok(S value)  { return new Ok<>(value); }
    static <S, F> Result<S, F> err(F error) { return new Err<>(error); }

    // Convenience
    default boolean isOk()  { return this instanceof Ok; }
    default boolean isErr() { return this instanceof Err; }
    default S unwrap()     { return ((Ok<S, F>) this).value(); }
    default F unwrapErr()   { return ((Err<S, F>) this).error(); }
}

Next we can create the domain error type:

public sealed interface UserError permits
        UserError.NotFound,
        UserError.AlreadyExists {

  record NotFound(UserId id)      implements UserError {}
  record AlreadyExists(Email email) implements UserError {}
}

And then we can create the domain objects:

public record UserId(String id) {}
public record Email() {}
public record CreateUserRequest(Email email) {}
public record User(UserId id, Email email) {}

And here’s a a sample service that uses it all.

@Service
public class UserService {

    public Result<User, UserError> getUserById(UserId id) {

        return userRepository.findById(id)
                .map(Result::<User, UserError>ok)
                .orElse(Result.err(new UserError.NotFound(id)));
    }

    public Result<User, UserError> createUser(CreateUserRequest req) {
        if (userRepository.existsByEmail(req.email())) {
            return Result.err(new UserError.AlreadyExists(req.email()));
        }

        User user = userRepository.save(req.toUser());
        return Result.ok(user);
    }
}

Leave a comment