- Use `Either AppError a` for pure functions that can fail — put the error type on the `Left` and the result on `Right`.
- Use `ExceptT AppError IO a` to thread typed errors through `IO` computations without wrapping every call in `try`/`catch`.
- Define a `newtype AppError = AppError { unAppError :: Text }` or a sum type for structured errors — never use bare `String` as an error type.
- Use `throwError` and `catchError` from `Control.Monad.Except` to raise and handle `ExceptT` errors — they compose cleanly in `do` notation.
- Catch synchronous exceptions at IO boundaries with `try :: Exception e => IO a -> IO (Either e a)` and convert them to your typed error domain.
- Use `MonadError AppError m` constraints on functions instead of `ExceptT AppError IO` directly — it decouples implementation from concrete monad stack.
- Log errors with structured context using `co-log` or `fast-logger` at the point of handling, not the point of throwing.