Errors and nulls Recommended
Use typed domain exceptions
Give each kind of failure its own exception type with a clear name and the data needed to handle it. Never throw a bare generic error with only a message.
How to do it
Create a small family of exceptions, such as PaymentDeclined and OutOfStock, that extend one base type for the domain. Catch by type, never by matching message text. Map each type to one answer at the edge, such as a 402 or a 409.
Example
# Bad: callers must parse the message
raise Exception("card declined")
# Good: callers catch the type, and it carries the data
class PaymentDeclined(ShopError):
def __init__(self, order_id: OrderId, reason: str):
super().__init__(f"Payment declined for {order_id}: {reason}")
self.order_id, self.reason = order_id, reason
How to check
A linter rule bans throwing the base type. Examples are Ruff's TRY002, ESLint no-restricted-syntax on throw new Error, and a custom PHPStan rule against throw new \Exception.
For agents: if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in Based on the Agent Wranglers architecture playbook: https://agentwranglers.se/playbook/architecture/errors-and-nulls/domain-exceptions.