0.0
A long-lived project that still receives updates
Interceptors provides a production-ready pattern for building use case objects with consistent results, interceptor pipelines, and instrumentation.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Development

>= 0

Runtime

 Project Readme

Interceptors

Interceptor-driven use case toolkit for Ruby and Rails applications.

Features

  • Consistent Result object with ok?/err? helpers and metadata.
  • Base UseCase class with interceptor pipeline support.
  • Built-in interceptors for logging, validation, retries, timeouts, transactions, and idempotency.
  • Uniform error taxonomy (AppError, ValidationError, AuthError) with HTTP semantics.
  • Instrumentation via ActiveSupport::Notifications.
  • Optional Rails responder helper for controllers or jobs.

Installation

Add the gem to your bundle:

gem "interceptors"

Usage

class CreateUser < Interceptors::UseCase
  use Interceptors::LoggingInterceptor.new
  use Interceptors::ValidationInterceptor.new do |ctx|
    errors = {}
    errors[:email] = "is required" if ctx[:email].to_s.strip.empty?
    errors
  end

  private

  def execute(ctx)
    user = User.create!(email: ctx[:email])
    Interceptors::Result.ok(user)
  rescue ActiveRecord::RecordInvalid => e
    Interceptors::Result.err(Interceptors::ValidationError.new(e.record.errors.to_hash))
  end
end

result = CreateUser.call(email: "user@example.com")
result.ok? #=> true

Richer example: checkout flow

class CheckoutOrder < Interceptors::UseCase
  use Interceptors::LoggingInterceptor.new
  use Interceptors::TimeoutInterceptor.new(seconds: 3)
  use Interceptors::RetryInterceptor.new(tries: 3, on: [ActiveRecord::Deadlocked])
  use Interceptors::TransactionInterceptor.new
  use Interceptors::IdempotencyInterceptor.new(key_proc: ->(ctx) { "checkout:#{ctx[:idempotency_key]}" })

  use Interceptors::ValidationInterceptor.new do |ctx|
    errors = {}
    errors[:cart_id] = "is required" if ctx[:cart_id].to_s.empty?
    errors[:payment_token] = "is required" if ctx[:payment_token].to_s.empty?
    errors
  end

  private

  def execute(ctx)
    guard_policy!(ctx[:actor], ctx[:cart_id])

    cart    = Cart.lock.find(ctx[:cart_id])
    payment = charge_payment!(ctx[:payment_token], cart.total_cents)

    order = persist_order!(cart, payment, ctx)

    Interceptors::Result.ok(order, meta: { order_id: order.id, payment_id: payment.id })
  rescue PaymentGateway::Error => e
    Interceptors::Result.err(
      Interceptors::AppError.new(e.message, code: "payment_failed", http_status: 422, details: { gateway: e.code })
    )
  end

  def guard_policy!(actor, cart_id)
    allowed = actor&.can?(:checkout, cart_id)
    raise Interceptors::AuthError.new unless allowed
  end

  def charge_payment!(token, amount_cents)
    PaymentGateway.charge!(token: token, amount_cents: amount_cents)
  end

  def persist_order!(cart, payment, ctx)
    Order.create!(
      user: ctx[:actor].user,
      total_cents: cart.total_cents,
      payment_reference: payment.id,
      shipping_address: ctx[:shipping_address]
    ).tap do |order|
      cart.line_items.each do |line_item|
        order.order_lines.create!(sku: line_item.sku, qty: line_item.quantity, price_cents: line_item.price_cents)
      end
    end
  end
end

result = CheckoutOrder.call(
  cart_id: params[:cart_id],
  payment_token: params[:payment_token],
  shipping_address: params[:shipping_address],
  idempotency_key: request.headers["Idempotency-Key"],
  actor: Current.session
)

if result.ok?
  render json: { order_id: result.value.id }, status: :created
else
  err = result.error
  render json: { error: err.code, message: err.message, details: err.details }, status: err.http_status
end

Using the mixin instead of inheritance

If you prefer not to inherit from Interceptors::UseCase, include the mixin to add the same DSL and runtime behaviour to any PORO:

class RefundOrder
  include Interceptors::UseCaseMixin

  use Interceptors::LoggingInterceptor.new

  def execute(ctx)
    refund = RefundProcessor.call!(order_id: ctx[:order_id])
    Interceptors::Result.ok(refund)
  rescue RefundProcessor::Error => e
    Interceptors::Result.err(Interceptors::AppError.new(e.message, code: "refund_failed"))
  end
end

Instrument use cases with ActiveSupport:

ActiveSupport::Notifications.subscribe("use_case.finish") do |_name, _start, _finish, _id, payload|
  Rails.logger.info("[UseCase] #{payload[:name]} ok=#{payload[:ok]}")
end

Writing custom interceptors

Interceptors respond to three optional hooks:

  • before(ctx) runs before the next step and can mutate the context or raise to halt execution.
  • around(ctx) { |ctx| ... } wraps the remainder of the pipeline; call yield ctx to continue or return a Result to short-circuit.
  • after(ctx, result) executes after the inner handler returns; return value is ignored unless you return a new Result.

To build your own interceptor:

class AuditInterceptor < Interceptors::Interceptor
  def before(ctx)
    AuditTrail.write(event: "start", use_case: ctx[:use_case])
  end

  def around(ctx)
    super
  rescue => e
    AuditTrail.write(event: "error", use_case: ctx[:use_case], error: e.class.name)
    raise
  end

  def after(_ctx, result)
    AuditTrail.write(event: "finish", ok: result.ok?)
    result
  end
end

class ProcessPayment < Interceptors::UseCase
  use AuditInterceptor.new

  # ...
end

Checklist for custom interceptors:

  1. Subclass Interceptors::Interceptor (or include behavior manually) and implement whichever hooks you need.
  2. Ensure around always yields or returns an Interceptors::Result to keep the pipeline consistent.
  3. Register the interceptor with use on your use case, or reuse it across multiple use cases.

For Rails controllers, include the responder helper:

class UsersController < ApplicationController
  include Interceptors::Rails::UseCaseResponder

  def create
    respond_with_use_case(CreateUser.call(user_params))
  end
end

Development

After checking out the repo, run bin/setup to install dependencies. Then run bundle exec rspec to run the tests.

License

The gem is available as open source under the terms of the MIT License.