class ApplicationError < StandardError
attr_reader :context
class << self
def context_keys(*keys)
@context_keys = keys unless keys.empty?
@context_keys || []
end
def default_context
{}
end
end
def initialize(message = nil, **context)
@context = self.class.default_context.merge(context).freeze
missing_context!
super(message || self.class.name.underscore.humanize)
end
def to_h
{
error: self.class.name,
message: message,
context: context
}
end
private
def missing_context!
missing = self.class.context_keys - context.keys
return if missing.empty?
raise ArgumentError, "#{self.class} missing required context: #{missing.join(', ')}"
end
end
module RaiseWithContext
def raise_with_context(**extra)
yield
rescue ApplicationError => e
raise e.class.new(e.message, **e.context.merge(extra))
end
end
class PaymentDeclinedError < ApplicationError
context_keys :order_id, :amount_cents, :code
end
class InventoryError < ApplicationError
context_keys :order_id, :sku
end
class CheckoutService
include RaiseWithContext
def initialize(order)
@order = order
end
def call
charge!
raise_with_context(cart_size: @order.line_items.size) do
reserve_inventory!
end
@order.mark_paid!
end
private
def charge!
result = PaymentGateway.charge(@order.amount_cents, token: @order.payment_token)
return if result.success?
raise PaymentDeclinedError.new(
"Card was declined by gateway",
order_id: @order.id,
amount_cents: @order.amount_cents,
code: result.decline_code
)
end
def reserve_inventory!
@order.line_items.each do |item|
next if Inventory.reserve(item.sku, item.quantity)
raise InventoryError.new(
"Out of stock",
order_id: @order.id,
sku: item.sku
)
end
end
end
class ApplicationController < ActionController::API
rescue_from ApplicationError, with: :render_structured_error
private
def render_structured_error(error)
payload = error.to_h.merge(request_id: request.request_id)
Rails.logger.error(payload.to_json)
report_to_sentry(error, payload[:context])
render json: {
error: payload[:error],
message: payload[:message]
}, status: status_for(error)
end
def report_to_sentry(error, context)
return unless defined?(Sentry)
Sentry.with_scope do |scope|
scope.set_extras(context)
scope.set_tags(error_class: error.class.name)
Sentry.capture_exception(error)
end
end
def status_for(error)
case error
when PaymentDeclinedError then :payment_required
when InventoryError then :conflict
else :unprocessable_entity
end
end
end
This snippet demonstrates a pattern for raising exceptions that carry structured context instead of bare strings, so that when an error surfaces in logs or an error tracker it already knows the order_id, user_id, and any other domain facts that made it happen. The core idea is that a stack trace tells where something broke, but a context payload tells why — and attaching that data at the raise site is far cheaper than reconstructing it later from scattered log lines.
In ApplicationError, a base class subclasses StandardError and accepts a message plus an arbitrary keyword context hash. The context is frozen and merged with a per-class default_context, and to_h produces a serializable record combining the error class, message, and context. Subclasses use the context_keys macro so that PaymentDeclinedError and InventoryError document exactly which structured fields they expect, and missing_context! enforces them at construction time — turning a forgotten field into an immediate, loud failure rather than a silent nil in a dashboard.
The raise_with_context helper in the same file shows how to enrich an exception mid-flight: it captures a caller-supplied block of context, merges it into whatever error bubbles up, and re-raises. This lets a low-level method throw a generic error while a higher layer annotates it with request-scoped facts like tenant_id without the lower layer needing to know about them.
In CheckoutService, the service raises PaymentDeclinedError with the concrete order_id, amount_cents, and gateway code, then wraps the inventory step in raise_with_context to layer on the cart contents. Because the context travels with the object, no rescue block needs to rebuild it.
Finally, ApplicationController centralizes handling via rescue_from ApplicationError. It calls to_h to log a single structured line, forwards the same hash to Sentry as extra data, and renders a clean JSON error to the client. The trade-off is a small amount of ceremony at each raise site in exchange for errors that are self-describing everywhere they land. This approach pays off most in multi-tenant or event-driven systems where reproducing a failure from a lone message string is painful.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.