An error is part of the signature, not a side channel for control flow. Exceptions break locality: any call can jump anywhere.
Java
// checked exceptions: a historically failed attempt at the same thing;
// the modern form is a Result-like sealed type for domain errors
sealed interface ChargeResult permits Charged, Declined, GatewayDown {}
record Charged(String txId) implements ChargeResult {}
record Declined(String reason) implements ChargeResult {}
record GatewayDown(Duration retryAfter) implements ChargeResult {}
// exceptions remain for programming errors (NPE, IllegalState)
// — that is the correct splitRust
// Result + the ? operator: the reference form of the approach
fn charge(card: &Card, amount: Cents) -> Result<TxId, ChargeError> {
let token = tokenize(card)?; // early return, the type stays honest
gateway::charge(token, amount) // ChargeError: an enum of every outcome
}
// thiserror for libraries, anyhow for applications: the settled boundaryGo
// (T, error) is the language idiom; the key is wrapping with context and errors.Is/As
func (s *Svc) Charge(ctx context.Context, c Card, amt Cents) (TxID, error) {
tok, err := s.tokenize(ctx, c)
if err != nil {
return "", fmt.Errorf("tokenize card ****%s: %w", c.Last4, err)
}
id, err := s.gw.Charge(ctx, tok, amt)
if err != nil {
if errors.Is(err, gateway.ErrDeclined) { return "", ErrDeclined }
return "", fmt.Errorf("gateway charge: %w", err)
}
return id, nil
}Python
# exceptions are the language idiom; fighting it costs more than accepting it.
# Compromise: domain outcomes as values, infrastructure failures as exceptions
from dataclasses import dataclass
@dataclass(frozen=True)
class Declined: reason: str
@dataclass(frozen=True)
class Charged: tx_id: str
def charge(card: Card, amount: int) -> Charged | Declined:
... # Declined is not an exception: it is an expected business outcome
# ConnectionError stays an exception: it is a failure, not an outcome