Опирается на правила:
R-ERR-RESULT-1…R-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 работают с исключениями.
@retrytenacity, 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. @retrytenacity настраивается на типы исключений, не наResult-значения — придётся переписывать.- FastAPI middleware прозрачно видит исключения и добавляет контекст;
Resultони не видят.
Если нужна type-safety — mypy с аннотированной иерархией исключений достаточно. Result не добавляет safety, которой нет при DomainError(AppError).
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Result[Order, OrderError] в UseCase Handler | R-ERR-RESULT-X1 | Order с raise DomainError |
Result в out-adapter вместо IntegrationError | R-ERR-RESULT-2 | Типизированный IntegrationError-наследник |
Result во всём сервисе «ради единообразия» | R-ERR-RESULT-X1 | Иерархия исключений — уже единообразие |
returns.Maybe вместо raise DomainError в домене | R-ERR-RESULT-X1 | raise ProductNotFoundError(product_id) |
Куда дальше
- Иерархия исключений — то, что заменяет Result в большинстве случаев.
- Где raise, где catch — почему ноль try/except при типизированных исключениях.
- Mapping в ProblemDetails — что edge делает с исключениями.
- Retry-семантика — почему tenacity работает с исключениями, не с Result.
- Логирование исключений — один лог на edge.
- Observability ошибок — метрики по типу исключения.
- Error Handling Style Guide → раздел 6 — нормативные формулировки.