Опирается на правила: R-ERR-RESULT-1R-ERR-RESULT-2 и R-ERR-RESULT-X1 из Error Handling Style Guide → раздел 6. Result-types vs exceptions.

Важно знать

  • Result<T, E> / sealed-interface вместо исключений — разрешено точечно, не как замена.
  • Где можно: чисто-функциональные модули, где ошибка — семантически часть результата (парсер: ParseResult<Token, ParseError>; calc engine: CalcResult<Money, CalcError>).
  • Где нельзя: цепочка UseCase Handler → Domain → Adapter. Каждый метод вынужден возвращать Result<T, MyError>, каждый caller — pattern-match'ить.
  • Java без полноценного pattern-matching превращает это в result.isOk() ? result.value() : throw result.error() — те же исключения, обёрнутые.
  • Глобальная замена исключений на Result ради «type-safe error handling» — запрещена. Не приносит type-safety поверх типизированной иерархии исключений.

В функциональных языках (Rust, Haskell, F#) Result<T, E> / Either<L, R> — стандартный способ обработки ошибок. Тип результата явно говорит «может быть успех или ошибка», компилятор заставляет обработать оба случая. В Java сейчас (Java 21) есть sealed interfaces и pattern matching (через switch), что позволяет имитировать этот стиль. Но полноценной экосистемы для этого в Spring-стеке нет: Spring Web, JPA, jOOQ работают с исключениями. Попытка натянуть Result на всё приложение оборачивается boilerplate'ом без пользы. Раскрытие правил R-ERR-RESULT-* ниже.

Где Result допустим

R-ERR-RESULT-1: чисто-функциональные модули, где ошибка — часть результата.

Примеры:

1. Парсер. Парсинг строки в Token — это вычислительная операция, и «не удалось разобрать» — это один из возможных результатов, не сбой системы.

public sealed interface ParseResult<T> {
    record Success<T>(T value) implements ParseResult<T> {}
    record Failure<T>(ParseError error) implements ParseResult<T> {}
}

public ParseResult<DateRange> parseDateRange(String input) {
    if (input == null || input.isBlank()) {
        return new ParseResult.Failure<>(new ParseError("Empty input"));
    }
    var parts = input.split("/");
    if (parts.length != 2) {
        return new ParseResult.Failure<>(new ParseError("Expected from/to"));
    }
    try {
        var from = LocalDate.parse(parts[0]);
        var to = LocalDate.parse(parts[1]);
        return new ParseResult.Success<>(new DateRange(from, to));
    } catch (DateTimeParseException e) {
        return new ParseResult.Failure<>(new ParseError("Invalid date format"));
    }
}

// Использование
var result = parseDateRange(input);
switch (result) {
    case ParseResult.Success<DateRange> s -> processRange(s.value());
    case ParseResult.Failure<DateRange> f -> reportError(f.error());
}

2. Calculation engine. Сложный расчёт скидки/комиссии, где «нельзя посчитать» — это бизнес-ответ, не сбой:

public sealed interface CalcResult<T> {
    record Computed<T>(T value, List<CalcRule> appliedRules) implements CalcResult<T> {}
    record Skipped<T>(CalcReason reason) implements CalcResult<T> {}
    record Invalid<T>(List<CalcError> errors) implements CalcResult<T> {}
}

Эти модули — редкие, обычно «вычислительный стейджик внутри одного use case». Они не пересекают границу UseCase → Domain → Adapter, остаются внутри одного слоя.

Где Result НЕ применим

R-ERR-RESULT-2: цепочка UseCase Handler → Domain → Adapter — исключения, не Result.

// ПЛОХО — Result везде
public Result<Order, OrderError> createOrder(CreateOrderCommand cmd) {
    var productResult = productRepository.findById(cmd.productId());
    if (productResult.isError()) {
        return Result.error(new OrderError.ProductNotFound(cmd.productId()));
    }
    var product = productResult.value();

    var orderResult = Order.create(cmd, product);
    if (orderResult.isError()) {
        return Result.error(new OrderError.DomainViolation(orderResult.error()));
    }
    var order = orderResult.value();

    var saveResult = orderRepository.save(order);
    if (saveResult.isError()) {
        return Result.error(new OrderError.PersistenceFailure(saveResult.error()));
    }

    var paymentResult = paymentPort.register(/* ... */);
    if (paymentResult.isError()) {
        return Result.error(new OrderError.PaymentFailed(paymentResult.error()));
    }

    return Result.ok(order);
}

vs

// ХОРОШО — исключения
@Transactional
public Order createOrder(CreateOrderCommand cmd) {
    var product = productRepository.findById(cmd.productId())
        .orElseThrow(() -> new ProductNotFoundException(cmd.productId()));

    var order = Order.create(cmd, product);                            // ← бросит DomainException если что
    orderRepository.save(order);                                       // ← бросит TechnicalException если что
    paymentPort.register(/* ... */);                                   // ← бросит IntegrationException если что

    return order;
}

Второй вариант:

  • Читается линейно. Никаких разворачиваний / unwrap.
  • Каждый caller не обязан pattern-match'ить — исключение само улетит до edge.
  • Типизация ошибок уже есть — через иерархию исключений (DomainException, IntegrationException).
  • Spring-механика работает. @Transactional ловит исключение и откатывает; advice превращает в HTTP-ответ; Resilience4j retry'ит. С Result всё это пришлось бы реализовывать заново.

Type-safety, которую обещает Result, в Java без полноценного pattern matching не доходит. Sealed interfaces помогли (Java 17+), но:

  • Нет автоматического сужения типа для ?. или if-выражений (как в Kotlin с smart casts).
  • Нет «нельзя забыть случай» — switch с sealed работает, но в реальном коде люди возвращаются к if (r instanceof Success<?> s) ... цепочкам.
  • Spring DI / @Transactional / @Valid / advice — всё построено на исключениях.

Что запрещено

R-ERR-RESULT-X1: глобальная замена исключений на Result ради «type-safe error handling».

// ПЛОХО — Result везде
public Result<Customer, CustomerError> findCustomer(Long id) { /* ... */ }
public Result<Order, OrderError> createOrder(...) { /* ... */ }
public Result<Payment, PaymentError> chargePayment(...) { /* ... */ }

Что неизбежно произойдёт:

// В каждом caller
var customerResult = findCustomer(id);
if (customerResult.isError()) {
    throw new RuntimeException(customerResult.error().toString());     // ← обратно в exception
}
var customer = customerResult.value();

Или:

var customer = findCustomer(id).orElseThrow(/* ... */);                // ← тот же throw

Получаем те же исключения, просто обёрнутые в Result wrapping. Те же яйца, в профиль. Плюс boilerplate в виде сотен .orElseThrow() и .isError() проверок.

Если хочется типизированных ошибок — у нас уже есть типизированная иерархия исключений (см. Иерархия исключений). Она даёт ровно то, что обещает Result, плюс совместима со всей Spring-инфраструктурой.

Куда дальше

  • Error Handling Style Guide → раздел 6. Result vs exceptions — нормативные формулировки.
  • Иерархия исключений — то, что заменяет Result в большинстве случаев.
  • Где throw, где catch — почему ноль try-catch при типизированных исключениях.