ruby 129 lines · 3 tabs

Structured Exceptions with Context

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Structured Exceptions with Context — share card
Link copied