Introduction
Ruby's built-in exception classes cover many common error scenarios, but real applications need domain-specific errors. Custom exception classes let you communicate exactly what went wrong and attach structured context that callers can act on.
Key Concepts
- Custom Exception Class: A class that inherits from
StandardError(or one of its subclasses) to represent a domain-specific error. - Exception Attributes: Extra data (like field names, status codes, or IDs) attached to an exception via
attr_readerto provide context beyond just a message string. - Exception Hierarchy: Organizing your custom exceptions into a tree so callers can rescue broadly or narrowly as needed.
Real World Context
In a web application, you might have a PaymentDeclined error, a UserNotFound error, and a RateLimited error. Each needs different handling: one triggers a customer notification, another returns a 404, and the third implements a retry with backoff. Without custom exceptions, you end up parsing error message strings, which is fragile and error-prone.
Deep Dive
Designing Custom Exceptions
Always inherit from StandardError, not Exception. This ensures your errors are caught by bare rescue blocks and do not interfere with system signals.
Here is a typical application error hierarchy:
rubyclass ApplicationError < StandardError; end class NotFoundError < ApplicationError; end class ValidationError < ApplicationError attr_reader :field def initialize(field, message) @field = field super("\#{field}: \#{message}") end end
ApplicationError serves as the base for all your app-specific errors. NotFoundError and ValidationError branch from it, letting callers rescue at whatever granularity they need.
Using Custom Exceptions
Custom exceptions integrate naturally with Ruby's begin/rescue blocks. Here is how you raise and catch them:
rubydef find_user(id) user = User.find_by(id: id) raise NotFoundError, "User \#{id} not found" unless user user end def validate_age(age) raise ValidationError.new(:age, "must be positive") if age < 0 end # Catching them begin user = find_user(999) rescue NotFoundError => e puts e.message rescue ApplicationError => e # Catches any ApplicationError subclass log_error(e) end
Notice that rescuing ApplicationError acts as a catch-all for your domain errors, while rescuing NotFoundError lets you handle that specific case differently.
Adding Context
For errors that originate from external services, you often need structured metadata. Here is an API error class with a status code and response body:
rubyclass APIError < StandardError attr_reader :status_code, :response_body def initialize(message, status_code:, response_body: nil) @status_code = status_code @response_body = response_body super(message) end end raise APIError.new("Rate limited", status_code: 429)
The status_code and response_body attributes give callers the information they need to decide whether to retry, alert, or fail gracefully.
Common Pitfalls
- Inheriting from Exception instead of StandardError — Your custom error will not be caught by bare
rescueblocks and may interfere with signal handling. Always inherit fromStandardErroror one of its descendants. - Flat exception hierarchies — Creating dozens of unrelated exception classes makes it impossible to rescue a group of related errors. Use a base class like
ApplicationErrorto enable broad rescues.
Best Practices
- Create a base ApplicationError — Having a single root for your domain errors lets you rescue all application errors in one clause when you need a safety net.
- Attach structured context via attr_reader — Instead of encoding information in the message string, use attributes so callers can programmatically inspect the error.
Summary
- Custom exceptions inherit from
StandardErrorand represent domain-specific error conditions. - Organize them into a hierarchy so callers can rescue broadly or narrowly.
- Attach structured context (field names, status codes, response bodies) as attributes rather than encoding them in message strings.
Code Examples
class ApplicationError < StandardError; end
class ValidationError < ApplicationError
attr_reader :field, :code
def initialize(field, message, code: nil)
@field = field
@code = code
super("#{field}: #{message}")
end
end
begin
raise ValidationError.new(:email, "is invalid", code: :format)
rescue ValidationError => e
puts e.message # => email: is invalid
puts e.field # => email
puts e.code # => format
end