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

Важно знать

  • returns.Result / Eitherдопустим точечно в чисто-функциональных модулях, где ошибка семантически часть результата.
  • Где можно: парсер, calculation engine — изолированные вычислительные модули внутри одного use case.
  • Где нельзя: цепочка UseCase Handler → Domain → Adapter. Каждый метод вынужден возвращать Result[T, E], каждый caller — разворачивать.
  • В Python без обязательного exhaustive matching (в отличие от Rust) Result вырождается в if not res.is_ok: raise res.error — те же исключения, обёрнутые.
  • Глобальная замена исключений на Result — запрещена. Не добавляет type-safety поверх типизированной иерархии исключений.

В Rust и Haskell Result<T, E> / Either<L, R> — идиоматичный стандарт. Компилятор заставляет обработать оба случая. В Python match есть с 3.10, но он не обязателенif isinstance(res, Failure) или res.is_ok работают без exhaustive-проверки компилятором. Попытка натянуть Result на весь Python-сервис приводит к boilerplate без safety. Раскрытие правил R-ERR-RESULT-* ниже.

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

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

Парсер входных данных

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

Примеры ниже используют синтаксис обобщённых типов PEP 695 (class ParseSuccess[T]:, type ParseResult[T] = ...) — нужен Python 3.12+; на 3.10–3.11 — TypeVar/Generic[T] и Union.

# core/parsers/date_range_parser.py
from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class ParseSuccess[T]:
    value: T


@dataclass(frozen=True)
class ParseFailure:
    message: str
    position: int | None = None


type ParseResult[T] = ParseSuccess[T] | ParseFailure


@dataclass(frozen=True)
class DateRange:
    start: date
    end: date


def parse_date_range(raw: str) -> ParseResult[DateRange]:
    if not raw or not raw.strip():
        return ParseFailure("empty input")
    parts = raw.split("/")
    if len(parts) != 2:
        return ParseFailure("expected format: YYYY-MM-DD/YYYY-MM-DD")
    try:
        start = date.fromisoformat(parts[0].strip())
        end = date.fromisoformat(parts[1].strip())
    except ValueError as e:
        return ParseFailure(f"invalid date: {e}")
    if start > end:
        return ParseFailure("start must be before end")
    return ParseSuccess(DateRange(start=start, end=end))

Использование с Python 3.10+ match:

result = parse_date_range(request_param)
match result:
    case ParseSuccess(value=date_range):
        orders = await self._orders.list_by_date(date_range)
    case ParseFailure(message=msg):
        raise InputValidationError(f"Invalid date range: {msg}")

Этот модуль — внутри UseCase Handler. Он не пересекает границу слоёв, остаётся изолированным.

Calculation engine

Сложный расчёт скидки или тарификации, где несколько исходов — норма:

# core/pricing/discount_engine.py
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum


class DiscountSkipReason(Enum):
    CUSTOMER_NOT_ELIGIBLE = "customer_not_eligible"
    PRODUCT_EXCLUDED = "product_excluded"
    PROMOTION_EXPIRED = "promotion_expired"


@dataclass(frozen=True)
class DiscountApplied:
    original: Decimal
    discount: Decimal
    final: Decimal
    rule_code: str


@dataclass(frozen=True)
class DiscountSkipped:
    reason: DiscountSkipReason


@dataclass(frozen=True)
class DiscountError:
    message: str


type DiscountResult = DiscountApplied | DiscountSkipped | DiscountError


class DiscountEngine:
    def calculate(
        self, product_id: str, customer_id: str, amount: Decimal
    ) -> DiscountResult:
        rule = self._find_rule(product_id)
        if rule is None:
            return DiscountSkipped(reason=DiscountSkipReason.PRODUCT_EXCLUDED)
        if not self._is_eligible(customer_id, rule):
            return DiscountSkipped(reason=DiscountSkipReason.CUSTOMER_NOT_ELIGIBLE)
        if rule.is_expired():
            return DiscountSkipped(reason=DiscountSkipReason.PROMOTION_EXPIRED)
        discount = amount * rule.rate
        return DiscountApplied(original=amount, discount=discount, final=amount - discount,
                               rule_code=rule.code)

Три исхода — не «ошибка», «успех», «неизвестно», а три семантически разных результата вычисления. Это именно тот случай для Result-стиля.

Использование в UseCase Handler:

async def handle(self, cmd: CreateOrderCommand) -> Order:
    product = await self._products.get_by_id(cmd.product_id)
    discount_result = self._discount_engine.calculate(
        product_id=cmd.product_id,
        customer_id=cmd.customer_id,
        amount=cmd.amount,
    )
    match discount_result:
        case DiscountApplied(final=final_amount, rule_code=rule):
            order = Order.create(cmd, product, amount=final_amount, discount_rule=rule)
        case DiscountSkipped():
            order = Order.create(cmd, product, amount=cmd.amount)
        case DiscountError(message=msg):
            raise TechnicalError(f"Discount engine failure: {msg}")
    await self._orders.save(order)
    return order

DiscountEngine — изолированный модуль внутри одного use case. Он не пересекает слои.

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

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

# ПЛОХО — Result везде в цепочке

from returns.result import Result, Success, Failure

class CreateOrderHandler:
    async def handle(
        self, cmd: CreateOrderCommand
    ) -> Result["Order", "OrderError"]:
        product_result = await self._products.get_by_id(cmd.product_id)
        if isinstance(product_result, Failure):
            return Failure(OrderError.ProductNotFound(cmd.product_id))

        order_result = Order.create(cmd, product_result.unwrap())
        if isinstance(order_result, Failure):
            return Failure(OrderError.DomainViolation(order_result.failure()))

        save_result = await self._orders.save(order_result.unwrap())
        if isinstance(save_result, Failure):
            return Failure(OrderError.PersistenceFailure(save_result.failure()))

        payment_result = await self._payment.register(
            RegisterCommand(order_id=order_result.unwrap().order_id, amount=cmd.amount)
        )
        if isinstance(payment_result, Failure):
            return Failure(OrderError.PaymentFailed(payment_result.failure()))

        return Success(order_result.unwrap())

vs

# ХОРОШО — исключения

class CreateOrderHandler:
    async def handle(self, cmd: CreateOrderCommand) -> Order:
        product = await self._products.get_by_id(cmd.product_id)
        if product is None:
            raise ProductNotFoundError(product_id=cmd.product_id)
        order = Order.create(cmd, product)           # ← бросит DomainError если что
        await self._orders.save(order)
        await self._payment.register(
            RegisterCommand(order_id=order.order_id, amount=order.total)
        )
        return order

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

  • Читается линейно. Никаких isinstance-проверок, unwrap(), разворачиваний.
  • Каждый caller не обязан обрабатывать Result — исключение улетит до edge само.
  • Типизация ошибок уже есть — через иерархию: DomainError, IntegrationError.
  • FastAPI / SQLAlchemy работают с исключениями. @retry tenacity, middleware, зависимости — всё построено на исключениях.

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

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

# ПЛОХО — Result везде
async def find_customer(customer_id: str) -> Result[Customer, CustomerError]: ...
async def create_order(cmd: CreateOrderCommand) -> Result[Order, OrderError]: ...
async def charge_payment(cmd: ChargeCommand) -> Result[ChargeResult, PaymentError]: ...

Что неизбежно произойдёт в каждом caller:

customer_result = await find_customer(cmd.customer_id)
if not customer_result.is_ok:
    raise RuntimeError(str(customer_result.failure()))  # ← обратно в exception

Или через returns:

customer = (await find_customer(cmd.customer_id)).unwrap()  # ← тот же raise

Получаем те же исключения, просто обёрнутые в Result. Плюс:

  • Boilerplate в каждом caller — сотни .unwrap() и isinstance(r, Failure) проверок.
  • Тип OrderError != тип CustomerError — caller должен знать про каждый. Типизированная иерархия исключений даёт то же: DomainError, IntegrationError — и edge знает про них без изменения каждого caller.
  • @retry tenacity настраивается на типы исключений, не на Result-значения — придётся переписывать.
  • FastAPI middleware прозрачно видит исключения и добавляет контекст; Result они не видят.

Если нужна type-safety — mypy с аннотированной иерархией исключений достаточно. Result не добавляет safety, которой нет при DomainError(AppError).

АнтипаттернПравилоЧто взамен
Result[Order, OrderError] в UseCase HandlerR-ERR-RESULT-X1Order с raise DomainError
Result в out-adapter вместо IntegrationErrorR-ERR-RESULT-2Типизированный IntegrationError-наследник
Result во всём сервисе «ради единообразия»R-ERR-RESULT-X1Иерархия исключений — уже единообразие
returns.Maybe вместо raise DomainError в доменеR-ERR-RESULT-X1raise ProductNotFoundError(product_id)

Куда дальше

  • Иерархия исключений — то, что заменяет Result в большинстве случаев.
  • Где raise, где catch — почему ноль try/except при типизированных исключениях.
  • Mapping в ProblemDetails — что edge делает с исключениями.
  • Retry-семантика — почему tenacity работает с исключениями, не с Result.
  • Логирование исключений — один лог на edge.
  • Observability ошибок — метрики по типу исключения.
  • Error Handling Style Guide → раздел 6 — нормативные формулировки.