Опирается на правила:
R-ERR-RESULT-1…R-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 при типизированных исключениях.