A UseCase pattern implementation library for Ruby. Framework-agnostic with a focus on simplicity and type safety.
Each UseCase handles exactly one business operation. This makes the code easier to understand, test, and maintain.
# Good: Single responsibility
class CreateUserUseCase < SenroUsecaser::Base
def call(input)
# Only user creation
end
end
# Bad: Multiple responsibilities
class CreateUserAndSendEmailUseCase < SenroUsecaser::Base
def call(input)
# User creation + email sending (should be separated)
end
endAll UseCases explicitly return success or failure. Instead of relying on exceptions, callers can handle results appropriately.
result = CreateUserUseCase.call(CreateUserUseCase::Input.new(name: "Taro", email: "taro@example.com"))
if result.success?
user = result.value
# Handle success
else
errors = result.errors
# Handle failure
endUseCase dependencies are injected through a DI Container. This enables easy mock substitution during testing and achieves loose coupling.
class CreateUserUseCase < SenroUsecaser::Base
depends_on :user_repository, UserRepository
depends_on :event_publisher, EventPublisher
class Input
#: (name: String, email: String, **untyped) -> void
def initialize(name:, email:, **_rest)
@name = name
@email = email
end
def name = @name
def email = @email
end
input Input
output User
def call(input)
user = user_repository.create(name: input.name, email: input.email)
event_publisher.publish(UserCreated.new(user))
success(user)
end
end
# In tests
container = SenroUsecaser::Container.new
container.register(:user_repository, MockUserRepository.new)
container.register(:event_publisher, MockEventPublisher.new)The DI Container and UseCases support hierarchical namespaces for organizing dependencies and controlling visibility.
container = SenroUsecaser::Container.new
# Register in root namespace (global)
container.register(:logger, Logger.new)
container.register(:config, AppConfig.new)
# Register in nested namespaces
container.namespace(:admin) do
register(:user_repository, AdminUserRepository.new)
register(:audit_logger, AuditLogger.new)
namespace(:reports) do
register(:report_generator, ReportGenerator.new)
register(:export_service, ExportService.new)
end
end
container.namespace(:public) do
register(:user_repository, PublicUserRepository.new)
endDependencies are resolved by looking up the current namespace and its ancestors (parents). Child namespaces are not accessible.
root
├── :logger ← accessible from anywhere
├── :config ← accessible from anywhere
├── admin
│ ├── :user_repository ← accessible from admin and admin::*
│ ├── :audit_logger ← accessible from admin and admin::*
│ └── reports
│ ├── :report_generator ← accessible only from admin::reports
│ └── :export_service ← accessible only from admin::reports
└── public
└── :user_repository ← accessible only from public and public::*
# UseCase in root namespace (default)
class CreateUserUseCase < SenroUsecaser::Base
depends_on :logger # resolves from root
def call(input); end
end
# UseCase in admin namespace
class Admin::CreateUserUseCase < SenroUsecaser::Base
namespace :admin
depends_on :user_repository # resolves from admin
depends_on :audit_logger # resolves from admin
depends_on :logger # resolves from root (inherited)
def call(input); end
end
# UseCase in admin::reports namespace
class Admin::Reports::GenerateReportUseCase < SenroUsecaser::Base
namespace "admin::reports"
depends_on :report_generator # resolves from admin::reports
depends_on :user_repository # resolves from admin (parent)
depends_on :logger # resolves from root (ancestor)
def call(input); end
endInstead of explicitly declaring namespace, you can enable automatic inference from the Ruby module structure:
SenroUsecaser.configure do |config|
config.infer_namespace_from_module = true
endWith this enabled, namespaces are automatically derived from module names:
# No explicit namespace declaration needed!
# Module "Admin" → namespace "admin"
module Admin
class CreateUserUseCase < SenroUsecaser::Base
depends_on :user_repository # resolves from admin namespace
def call(input); end
end
end
# Module "Admin::Reports" → namespace "admin::reports"
module Admin
module Reports
class GenerateReportUseCase < SenroUsecaser::Base
depends_on :report_generator # resolves from admin::reports
depends_on :user_repository # resolves from admin (parent)
def call(input); end
end
end
end
# Top-level class → no namespace (root)
class CreateUserUseCase < SenroUsecaser::Base
depends_on :logger # resolves from root
def call(input); end
endThis also works for Providers:
module Admin
class ServiceProvider < SenroUsecaser::Provider
# Automatically registers in "admin" namespace
def register(container)
container.register(:admin_service, AdminService.new)
end
end
endNote: Explicit namespace declarations take precedence over inferred namespaces.
Create child containers for request-scoped dependencies (e.g., current_user):
# Global container with lazy registration
SenroUsecaser.container.register_lazy(:task_repository) do |c|
TaskRepository.new(current_user: c.resolve(:current_user))
end
# Per-request: create scoped container with current_user
request_container = SenroUsecaser.container.scope do
register(:current_user, current_user)
end
# UseCase resolves task_repository with correct current_user
ListTasksUseCase.call(input, container: request_container)For large applications, dependencies can be organized into Provider classes across multiple files. Providers declare their dependencies on other providers, ensuring correct load order.
# app/providers/core_provider.rb
class CoreProvider < SenroUsecaser::Provider
def register(container)
container.register(:logger, Logger.new(STDOUT))
container.register(:config, AppConfig.load)
end
end
# app/providers/persistence_provider.rb
class PersistenceProvider < SenroUsecaser::Provider
depends_on CoreProvider # Ensures CoreProvider loads first
def register(container)
container.register_singleton(:database) do |c|
Database.connect(c.resolve(:config))
end
end
end
# app/providers/admin_provider.rb
class AdminProvider < SenroUsecaser::Provider
depends_on CoreProvider
depends_on PersistenceProvider
namespace :admin # Register in admin namespace
def register(container)
container.register(:user_repository, AdminUserRepository.new)
end
end# config/initializers/senro_usecaser.rb
SenroUsecaser.configure do |config|
config.providers = [
CoreProvider,
PersistenceProvider,
AdminProvider
]
end
# Boot resolves dependencies and loads in correct order:
# 1. CoreProvider (no dependencies)
# 2. PersistenceProvider (depends on Core)
# 3. AdminProvider (depends on Core, Persistence)
SenroUsecaser.boot!class PersistenceProvider < SenroUsecaser::Provider
depends_on CoreProvider
# Called before register
def before_register(container)
# Setup work
end
def register(container)
container.register_singleton(:database) do |c|
Database.connect(c.resolve(:config))
end
end
# Called after all providers are registered
def after_boot(container)
container.resolve(:database).verify_connection!
end
# Called on application shutdown
def shutdown(container)
container.resolve(:database).disconnect
end
endclass PersistenceProvider < SenroUsecaser::Provider
def register(container)
# Eager: value stored directly
container.register(:config, AppConfig.load)
# Lazy: block called on every resolve
container.register_lazy(:connection) do |c|
Database.connect(c.resolve(:config))
end
# Singleton: block called once, result cached
container.register_singleton(:connection_pool) do |c|
ConnectionPool.new(size: 10) { c.resolve(:connection) }
end
end
endclass DevelopmentProvider < SenroUsecaser::Provider
enabled_if { SenroUsecaser.env.development? }
def register(container)
container.register(:mailer, DevelopmentMailer.new)
end
end
class ProductionProvider < SenroUsecaser::Provider
enabled_if { SenroUsecaser.env.production? }
def register(container)
container.register(:mailer, SmtpMailer.new)
end
endThe container ensures providers are loaded in topological order based on dependencies. Circular dependencies are detected and raise an error at boot time.
Dependency injection allows unit testing UseCases without relying on external services or databases.
RSpec.describe CreateUserUseCase do
let(:user_repository) { instance_double(UserRepository) }
let(:use_case) { described_class.new(dependencies: { user_repository: user_repository }) }
it "creates a user" do
allow(user_repository).to receive(:create).and_return(user)
input = CreateUserUseCase::Input.new(name: "Taro", email: "taro@example.com")
result = use_case.call(input)
expect(result).to be_success
end
endImplemented in pure Ruby, it can be used with any framework such as Rails, Sinatra, or Hanami.
All implementations are designed to be type-safe using RBS Inline comments. Types are written directly in Ruby source files as comments.
Each UseCase defines its Input and Output as inner classes with RBS Inline annotations:
class CreateUserUseCase < SenroUsecaser::Base
class Input
#: (name: String, email: String, ?age: Integer, **untyped) -> void
def initialize(name:, email:, age: nil, **_rest)
@name = name #: String
@email = email #: String
@age = age #: Integer?
end
#: () -> String
def name = @name
#: () -> String
def email = @email
#: () -> Integer?
def age = @age
end
class Output
#: (user: User, token: String) -> void
def initialize(user:, token:)
@user = user #: User
@token = token #: String
end
#: () -> User
def user = @user
#: () -> String
def token = @token
end
input Input
output Output
def call(input)
user = User.create(name: input.name, email: input.email, age: input.age)
token = generate_token(user)
success(Output.new(user: user, token: token))
end
endThe **_rest parameter in Input's initialize allows extra fields to be passed through pipeline steps without errors.
In addition to static type checking with RBS, SenroUsecaser provides runtime type validation for Input and Output. This ensures that the actual values passed at runtime match the expected types.
The input declaration supports three patterns:
When a Class is specified, input must be an instance of that class:
class CreateUserUseCase < SenroUsecaser::Base
input CreateUserInput # Class
def call(input)
# input must be a CreateUserInput instance
success(input.name)
end
end
# OK
CreateUserUseCase.call(CreateUserInput.new(name: "Taro"))
# ArgumentError: Input must be an instance of CreateUserInput, got String
CreateUserUseCase.call("invalid")When a Module is specified, input's class must include that module. This enables duck-typing with explicit interface contracts:
# Define interface
module HasUserId
def user_id
raise NotImplementedError
end
end
# UseCase expects input that includes HasUserId
class FindUserUseCase < SenroUsecaser::Base
input HasUserId
#: (HasUserId) -> SenroUsecaser::Result[User]
def call(input)
user = User.find(input.user_id)
success(user)
end
end
# Input class that implements the interface
class UserQuery
include HasUserId
attr_reader :user_id
def initialize(user_id:)
@user_id = user_id
end
end
# OK - UserQuery includes HasUserId
FindUserUseCase.call(UserQuery.new(user_id: 123))
# ArgumentError: Input UserQuery must include HasUserId
class InvalidInput
attr_reader :user_id
def initialize(user_id:) = @user_id = user_id
end
FindUserUseCase.call(InvalidInput.new(user_id: 123))Multiple Modules can be specified. The input must include ALL of them:
module HasUserId
def user_id = raise NotImplementedError
end
module HasEmail
def email = raise NotImplementedError
end
# UseCase requires both interfaces
class NotifyUserUseCase < SenroUsecaser::Base
input HasUserId, HasEmail
#: ((HasUserId & HasEmail)) -> SenroUsecaser::Result[bool]
def call(input)
notify(input.user_id, input.email)
success(true)
end
end
# Input class must include both modules
class NotificationRequest
include HasUserId
include HasEmail
attr_reader :user_id, :email
def initialize(user_id:, email:)
@user_id = user_id
@email = email
end
end
# OK
NotifyUserUseCase.call(NotificationRequest.new(user_id: 123, email: "test@example.com"))Interface validation is especially useful for sub-UseCases in pipelines. A parent UseCase's Input can include multiple interfaces, and each step only requires the interfaces it needs:
# Parent UseCase - Input includes both interfaces
class ProcessOrderUseCase < SenroUsecaser::Base
class Input
include HasUserId
include HasEmail
attr_reader :user_id, :email, :order_items
def initialize(user_id:, email:, order_items:)
@user_id = user_id
@email = email
@order_items = order_items
end
end
input Input
organize do
step FindUserUseCase # Only needs HasUserId
step NotifyUserUseCase # Needs HasUserId and HasEmail
step CreateOrderUseCase
end
endWhen output is declared with a Class, the success result's value is validated:
class UserOutput
attr_reader :user
def initialize(user:) = @user = user
end
class FindUserUseCase < SenroUsecaser::Base
input FindUserInput
output UserOutput # Class declaration enables validation
def call(input)
user = User.find(input.user_id)
success(UserOutput.new(user: user)) # OK
# TypeError: Output must be an instance of UserOutput, got User
# success(user) # Wrong! Must wrap in UserOutput
end
endNote: When output is a Hash schema (e.g., output({ user: User })), validation is skipped for backwards compatibility.
Note: Type validation errors raise exceptions (ArgumentError for input, TypeError for output). See .call vs .call! for how exceptions are handled.
Define UseCases with minimal boilerplate. Avoids over-abstraction and provides an intuitive API.
Complex business operations can be composed from simpler UseCases using organize and extend_with.
Execute multiple UseCases in sequence. Each step's output object becomes the next step's input directly (type chaining).
Important: All pipeline steps must define an input class. The output of step A should be compatible with the input of step B.
For basic pipelines without conditions, pass UseCase classes directly as arguments:
class PlaceOrderUseCase < SenroUsecaser::Base
input PlaceOrderInput
output CreateOrderOutput
# Simple sequential execution
organize ValidateOrderUseCase, CreateOrderUseCase, ChargePaymentUseCase, SendConfirmationEmailUseCase
endFor advanced control (conditional execution, per-step error handling, input mapping), use the block syntax:
class PlaceOrderUseCase < SenroUsecaser::Base
class Input
#: (user_id: Integer, product_ids: Array[Integer], **untyped) -> void
def initialize(user_id:, product_ids:, **_rest)
@user_id = user_id
@product_ids = product_ids
end
def user_id = @user_id
def product_ids = @product_ids
end
input Input
output CreateOrderOutput
# Each step's output becomes the next step's input:
# PlaceOrderUseCase::Input -> ValidateOrderUseCase
# ValidateOrderUseCase::Output -> CreateOrderUseCase::Input
# CreateOrderUseCase::Output -> ChargePaymentUseCase::Input
# ChargePaymentUseCase::Output -> SendConfirmationEmailUseCase::Input
# SendConfirmationEmailUseCase::Output -> CreateOrderOutput
organize do
step ValidateOrderUseCase
step CreateOrderUseCase
step ChargePaymentUseCase
step SendConfirmationEmailUseCase
end
endConfigure how errors are handled using the on_failure: option.
:stop (default) - Stop execution on first failure.
class PlaceOrderUseCase < SenroUsecaser::Base
organize on_failure: :stop do
step ValidateOrderUseCase
step CreateOrderUseCase
step ChargePaymentUseCase # Not executed if CreateOrderUseCase fails
end
end:continue - Continue execution even if a step fails.
class BatchProcessUseCase < SenroUsecaser::Base
organize on_failure: :continue do
step ProcessItemAUseCase
step ProcessItemBUseCase # Executed even if A fails
step ProcessItemCUseCase
end
end:collect - Continue execution and collect all errors.
class ValidateFormUseCase < SenroUsecaser::Base
organize on_failure: :collect do
step ValidateNameUseCase
step ValidateEmailUseCase
step ValidatePasswordUseCase
end
end
result = ValidateFormUseCase.call(input)
result.errors # => [name_error, email_error, password_error]class PlaceOrderUseCase < SenroUsecaser::Base
organize do
step ValidateOrderUseCase
step CreateOrderUseCase
step SendConfirmationEmailUseCase, on_failure: :continue # Don't fail if email fails
step NotifyAnalyticsUseCase, on_failure: :continue # Optional step
end
endUse if: or unless: options to conditionally execute steps.
class PlaceOrderUseCase < SenroUsecaser::Base
organize do
step ValidateOrderUseCase
step ApplyCouponUseCase, if: :has_coupon?
step CreateOrderUseCase
step ChargePaymentUseCase, unless: :free_order?
step SendGiftNotificationUseCase, if: :gift_order?
end
private
# Condition methods receive the current input object (output from previous step)
def has_coupon?(input)
input.coupon_code.present?
end
def free_order?(input)
input.total.zero?
end
def gift_order?(input)
input.gift_recipient.present?
end
endclass PlaceOrderUseCase < SenroUsecaser::Base
organize do
step ValidateOrderUseCase
step ApplyCouponUseCase, if: ->(input) { input.coupon_code.present? }
step NotifyAdminUseCase, if: ->(input) { input.total > 10_000 }
end
endCombine multiple conditions with all: (AND) or any: (OR):
class PlaceOrderUseCase < SenroUsecaser::Base
organize do
step ValidateOrderUseCase
# Runs only if ALL conditions are true
step PremiumDiscountUseCase, all: [:premium_user?, :eligible_for_discount?]
# Runs if ANY condition is true
step SendNotificationUseCase, any: [:email_opted_in?, :sms_opted_in?]
end
endBy default, the previous step's output object is passed directly as input to the next step. Use input: to transform it:
class PlaceOrderUseCase < SenroUsecaser::Base
organize do
step ValidateOrderUseCase
step CreateOrderUseCase
# Method reference - transform current input for next step
step SendEmailUseCase, input: :prepare_email_input
# Lambda - transform current input
step NotifyUseCase, input: ->(input) { NotifyInput.new(message: "Order #{input.order_id}") }
end
def prepare_email_input(input)
SendEmailInput.new(to: input.customer_email, subject: "Order Confirmation")
end
endAdd cross-cutting concerns like logging, authorization, or transaction handling.
# Define extension modules
module Logging
def self.before(input)
puts "Starting: #{input.class.name}"
end
def self.after(input, result)
puts "Finished: #{result.success? ? 'success' : 'failure'}"
end
end
module Transaction
def self.around(input, &block)
ActiveRecord::Base.transaction { block.call }
end
end
# Apply to UseCase
class CreateUserUseCase < SenroUsecaser::Base
extend_with Logging, Transaction
def call(input)
# main logic
end
endBlock hooks are executed in the UseCase instance context, allowing access to depends_on dependencies.
class CreateUserUseCase < SenroUsecaser::Base
depends_on :logger
depends_on :metrics
input Input
# before/after blocks can access dependencies directly
before do |input|
logger.info("Starting with #{input.class.name}")
end
after do |input, result|
logger.info("Finished: #{result.success? ? 'success' : 'failure'}")
metrics.increment(:use_case_completed)
end
# around block receives use_case as second argument for dependency access
around do |input, use_case, &block|
use_case.logger.info("Transaction start")
result = ActiveRecord::Base.transaction { block.call }
use_case.logger.info("Transaction end")
result
end
def call(input)
# main logic
end
endFor more complex hooks with their own dependencies, use SenroUsecaser::Hook class:
class LoggingHook < SenroUsecaser::Hook
depends_on :logger
depends_on :metrics
def before(input)
logger.info("Starting with #{input.class.name}")
end
def after(input, result)
logger.info("Finished: #{result.success? ? 'success' : 'failure'}")
metrics.increment(:use_case_completed)
end
def around(input)
logger.info("Around start")
result = yield
logger.info("Around end")
result
end
end
class CreateUserUseCase < SenroUsecaser::Base
extend_with LoggingHook
def call(input)
# main logic
end
endHook classes support:
depends_onfor dependency injectionnamespacefor scoped dependency resolution- Automatic namespace inference from module structure (when
infer_namespace_from_moduleis enabled) - Inheriting namespace from the UseCase if not explicitly declared
# Hook with explicit namespace
class Admin::AuditHook < SenroUsecaser::Hook
namespace :admin
depends_on :audit_logger
def after(input, result)
audit_logger.log(action: "create", success: result.success?)
end
end
# Hook inheriting namespace from UseCase
class MetricsHook < SenroUsecaser::Hook
depends_on :metrics # resolved from UseCase's namespace
def after(input, result)
metrics.increment(:completed)
end
end
class Admin::CreateUserUseCase < SenroUsecaser::Base
namespace :admin
extend_with MetricsHook # metrics resolved from :admin namespace
def call(input)
# ...
end
endThe SenroUsecaser::DependsOn module can be used in any class to enable the same dependency injection features available in UseCase and Hook classes. This is useful for services, repositories, or other application components that need DI support.
Basic Usage (No initialize needed)
When you extend DependsOn, a default initialize is provided automatically. If no container is passed, it uses SenroUsecaser.container:
class OrderService
extend SenroUsecaser::DependsOn
depends_on :order_repository, OrderRepository
depends_on :payment_gateway, PaymentGateway
depends_on :logger, Logger
# No initialize needed! Default is provided automatically.
def process_order(order_id)
order = order_repository.find(order_id)
logger.info("Processing order #{order_id}")
payment_gateway.charge(order.total)
end
end
# Usage - uses SenroUsecaser.container by default
service = OrderService.new
service.process_order(123)
# Or with explicit container
service = OrderService.new(container: custom_container)Custom initialize with super
If you need additional parameters, define your own initialize and call super:
class OrderService
extend SenroUsecaser::DependsOn
depends_on :order_repository, OrderRepository
attr_reader :default_currency
def initialize(default_currency: "JPY", container: nil)
super(container: container) # Handles dependency resolution
@default_currency = default_currency
end
def process_order(order_id)
order = order_repository.find(order_id)
order.charge(currency: default_currency)
end
end
# Uses SenroUsecaser.container by default
service = OrderService.new(default_currency: "USD")
service.default_currency # => "USD"
service.order_repository # => OrderRepository instanceWith Namespace
class Admin::ReportService
extend SenroUsecaser::DependsOn
namespace :admin
depends_on :report_generator, ReportGenerator
depends_on :logger, Logger # Falls back to root namespace
# No initialize needed!
def generate_monthly_report
logger.info("Generating monthly report")
report_generator.generate(:monthly)
end
endWith Automatic Namespace Inference
When infer_namespace_from_module is enabled, the namespace is automatically derived from the module structure:
SenroUsecaser.configure do |config|
config.infer_namespace_from_module = true
end
module Admin
module Reports
class ExportService
extend SenroUsecaser::DependsOn
# No explicit namespace needed!
# Automatically uses "admin::reports" namespace
depends_on :exporter, Exporter # from admin::reports
depends_on :storage, Storage # from admin (fallback)
depends_on :logger, Logger # from root (fallback)
# No initialize needed!
def export(data)
result = exporter.export(data)
storage.save(result)
logger.info("Export completed")
end
end
end
endFeatures provided by DependsOn:
depends_on :name, Type- Declare dependencies with optional type hintsnamespace :name- Set explicit namespace for dependency resolutiondeclared_namespace- Get the declared namespacedependencies- List of declared dependency namesdependency_types- Hash of dependency name to typecopy_depends_on_to(subclass)- Copy configuration to subclasses (for inheritance)
Instance methods (via InstanceMethods module):
initialize(container: nil)- Default initialize that sets up dependency injection. UsesSenroUsecaser.containerif no container is provided.resolve_dependencies- Resolve all declared dependencies from the containereffective_namespace- Get the namespace used for resolution (declared or inferred)
Custom initialize (full override):
If you need complete control, you can manually set up the required instance variables:
def initialize(extra:, container: nil)
@_container = container || SenroUsecaser.container
@_dependencies = {}
@extra = extra
resolve_dependencies
endThe on_failure hook is called only when the UseCase execution results in a failure. Unlike after which is always called, on_failure provides a dedicated hook for error handling, logging, or recovery logic.
Block Syntax
class CreateUserUseCase < SenroUsecaser::Base
depends_on :logger
depends_on :error_notifier
input Input
# on_failure block is executed in UseCase instance context
# allowing access to dependencies
on_failure do |input, result|
logger.error("Failed to create user: #{result.errors.map(&:message).join(', ')}")
error_notifier.notify(
action: "create_user",
input: input,
errors: result.errors
)
end
def call(input)
user = User.create!(name: input.name, email: input.email)
success(user)
rescue ActiveRecord::RecordInvalid => e
failure(Error.new(code: :validation_error, message: e.message))
end
endModule Syntax (with extend_with)
module ErrorLogging
def self.on_failure(input, result)
Rails.logger.error("UseCase failed: #{result.errors.first&.message}")
end
end
class CreateUserUseCase < SenroUsecaser::Base
extend_with ErrorLogging
def call(input)
# ...
end
endHook Class Syntax
class ErrorNotificationHook < SenroUsecaser::Hook
depends_on :error_notifier
depends_on :logger
def on_failure(input, result)
logger.error("UseCase failed with #{result.errors.size} error(s)")
error_notifier.notify(
errors: result.errors,
input_class: input.class.name,
timestamp: Time.current
)
end
end
class CreateUserUseCase < SenroUsecaser::Base
extend_with ErrorNotificationHook
def call(input)
# ...
end
endExecution Order
When a UseCase fails, hooks are executed in the following order:
aroundhooks (unwinding)afterhooks (always called, regardless of success/failure)on_failurehooks (only called whenresult.failure?is true)
class CreateUserUseCase < SenroUsecaser::Base
after do |input, result|
puts "after: #{result.success? ? 'success' : 'failure'}"
end
on_failure do |input, result|
puts "on_failure: handling error..."
end
def call(input)
failure(Error.new(code: :error, message: "Something went wrong"))
end
end
# Output:
# after: failure
# on_failure: handling error...Use Cases for on_failure
- Error logging: Log detailed error information for debugging
- Error notification: Send alerts to monitoring systems (Sentry, Bugsnag, etc.)
- Cleanup operations: Rollback partial state changes on failure
- Retry preparation: Queue failed operations for retry
- Metrics: Increment failure counters for observability
When using organize pipelines, the on_failure hook provides rollback behavior. If a step fails, the on_failure hooks of all previously successful steps are executed in reverse order.
This enables compensation logic (Saga pattern) where each step can define how to undo its changes when a later step fails.
class CreateOrderUseCase < SenroUsecaser::Base
input Input
on_failure do |input, result|
# Called if a later step fails
Order.find_by(id: input.order_id)&.destroy
puts "Rolled back: order creation"
end
def call(input)
order = Order.create!(user_id: input.user_id)
success(Output.new(order_id: order.id, user_id: input.user_id))
end
end
class ReserveInventoryUseCase < SenroUsecaser::Base
input Input
on_failure do |input, result|
# Called if a later step fails
Inventory.release(order_id: input.order_id)
puts "Rolled back: inventory reservation"
end
def call(input)
Inventory.reserve(order_id: input.order_id)
success(input)
end
end
class ChargePaymentUseCase < SenroUsecaser::Base
input Input
on_failure do |input, result|
# Called when this step itself fails (no rollback needed for self)
puts "Payment failed: #{result.errors.first&.message}"
end
def call(input)
# Payment fails
failure(Error.new(code: :payment_failed, message: "Insufficient funds"))
end
end
class PlaceOrderUseCase < SenroUsecaser::Base
input Input
organize do
step CreateOrderUseCase # Step 1: Success
step ReserveInventoryUseCase # Step 2: Success
step ChargePaymentUseCase # Step 3: Failure!
end
end
result = PlaceOrderUseCase.call(input)
# Output (in order):
# Payment failed: Insufficient funds <- ChargePaymentUseCase.on_failure
# Rolled back: inventory reservation <- ReserveInventoryUseCase.on_failure
# Rolled back: order creation <- CreateOrderUseCase.on_failureExecution Flow on Pipeline Failure:
Step A (success) → Step B (success) → Step C (failure)
↓
C.on_failure (failed step)
↓
B.on_failure (rollback)
↓
A.on_failure (rollback)
Important Notes:
-
Reverse order:
on_failurehooks are called in reverse order of successful execution, ensuring proper cleanup sequence. -
Input context: Each step's
on_failurereceives the input that was passed to that specific step (the output of the previous step), not the original pipeline input. -
Failed step included: The step that failed also has its
on_failurecalled (first, before rollback of previous steps). -
on_failure: :continue steps: Steps marked with
on_failure: :continuethat fail will have theiron_failurehook called, but won't trigger rollback of previous steps since the pipeline continues. -
Independent of on_failure_strategy: The rollback behavior works consistently with
:stop,:continue, and:collectstrategies. For:collect, rollback occurs after all steps have been attempted.
class PlaceOrderUseCase < SenroUsecaser::Base
organize do
step CreateOrderUseCase
step SendNotificationUseCase, on_failure: :continue # Optional step
step ChargePaymentUseCase
end
end
# If SendNotificationUseCase fails:
# - SendNotificationUseCase.on_failure is called
# - Pipeline continues (no rollback of CreateOrderUseCase)
# - ChargePaymentUseCase executes
# If ChargePaymentUseCase fails:
# - ChargePaymentUseCase.on_failure is called
# - CreateOrderUseCase.on_failure is called (rollback)
# - SendNotificationUseCase.on_failure is NOT called (it was optional and didn't cause the failure)SenroUsecaser provides retry functionality similar to ActiveJob, allowing automatic retry of failed UseCases with configurable strategies.
Use retry_on to configure automatic retry for specific error codes or exception types:
class FetchExternalDataUseCase < SenroUsecaser::Base
input Input
# Retry up to 3 times for specific error codes
retry_on :network_error, :timeout_error, attempts: 3
# Retry on specific exception types
retry_on NetworkError, Timeout::Error, attempts: 5, wait: 1.second
def call(input)
response = ExternalAPI.fetch(input.url)
success(response)
rescue Timeout::Error => e
failure(Error.new(code: :timeout_error, message: e.message, cause: e))
end
end| Option | Description | Default |
|---|---|---|
attempts |
Maximum number of retry attempts | 3 |
wait |
Time to wait between retries (seconds or Duration) | 0 |
backoff |
Backoff strategy (:fixed, :linear, :exponential) |
:fixed |
max_wait |
Maximum wait time when using backoff | 1 hour |
jitter |
Add randomness to wait time (0.0 to 1.0) | 0 |
class ProcessPaymentUseCase < SenroUsecaser::Base
input Input
# Exponential backoff: 1s, 2s, 4s, 8s... (capped at 30s)
retry_on :gateway_error,
attempts: 5,
wait: 1.second,
backoff: :exponential,
max_wait: 30.seconds
# Linear backoff with jitter: 2s, 4s, 6s... (±20% randomness)
retry_on :rate_limited,
attempts: 10,
wait: 2.seconds,
backoff: :linear,
jitter: 0.2
def call(input)
PaymentGateway.charge(input.amount)
end
endThe backoff option controls how wait time increases between retry attempts:
:fixed (default) - Same wait time for every retry.
wait: 2s
Attempt 1 fails → wait 2s → Attempt 2
Attempt 2 fails → wait 2s → Attempt 3
Attempt 3 fails → wait 2s → Attempt 4
Best for: Temporary errors expected to recover quickly.
:linear - Wait time increases by a fixed amount each retry.
wait: 2s (formula: wait × attempt)
Attempt 1 fails → wait 2s → Attempt 2
Attempt 2 fails → wait 4s → Attempt 3
Attempt 3 fails → wait 6s → Attempt 4
Attempt 4 fails → wait 8s → Attempt 5
Best for: Load-related errors where gradual recovery is expected.
:exponential - Wait time doubles each retry (most aggressive backoff).
wait: 2s (formula: wait × 2^(attempt-1))
Attempt 1 fails → wait 2s → Attempt 2
Attempt 2 fails → wait 4s → Attempt 3
Attempt 3 fails → wait 8s → Attempt 4
Attempt 4 fails → wait 16s → Attempt 5
Best for: Rate limiting, server overload, external API throttling.
Summary:
| Strategy | Formula | Use Case |
|---|---|---|
:fixed |
wait |
Quick recovery expected |
:linear |
wait × attempt |
Gradual recovery expected |
:exponential |
wait × 2^(attempt-1) |
Rate limits, heavy load |
max_wait caps the wait time to prevent excessive delays with :exponential:
retry_on :api_error,
wait: 1.second,
backoff: :exponential,
max_wait: 30.seconds # Never wait more than 30s
# Without max_wait, attempt 10 would wait 512 seconds (8.5 minutes)!
# With max_wait: 30s, it caps at 30 secondsjitter adds randomness to prevent thundering herd problem when many processes retry simultaneously:
retry_on :api_error,
wait: 2.seconds,
backoff: :exponential,
jitter: 0.25 # ±25% randomness
# Attempt 2 wait: 4s ± 1s (3s to 5s)
# Attempt 3 wait: 8s ± 2s (6s to 10s)For more control, use retry! within an on_failure block:
class SendEmailUseCase < SenroUsecaser::Base
input Input
on_failure do |input, result, context|
if result.errors.any? { |e| e.code == :temporary_failure }
# Retry with same input
retry! if context.attempt < 3
# Or retry with modified input
retry!(input: ModifiedInput.new(input, fallback: true))
# Or retry after delay
retry!(wait: 5.seconds) if context.attempt < 5
end
end
def call(input)
Mailer.send(input.to, input.subject, input.body)
end
endThe on_failure block receives a context object with retry information:
on_failure do |input, result, context|
context.attempt # Current attempt number (1, 2, 3...)
context.max_attempts # Maximum attempts configured (nil if unlimited)
context.retried? # Whether this is a retry (attempt > 1)
context.elapsed_time # Total time elapsed since first attempt
context.last_error # The error from the previous attempt (if retried)
endUse discard_on to skip retry for specific errors:
class CreateUserUseCase < SenroUsecaser::Base
input Input
# Always retry on these
retry_on :database_error, attempts: 3
# Never retry on these (fail immediately)
discard_on :validation_error, :duplicate_record
def call(input)
User.create!(input.to_h)
end
endclass ProcessOrderUseCase < SenroUsecaser::Base
input Input
retry_on :payment_error, attempts: 3
# Called before each retry attempt
before_retry do |input, result, context|
logger.warn("Retrying attempt #{context.attempt + 1}...")
end
# Called when all retry attempts are exhausted
after_retries_exhausted do |input, result, context|
logger.error("All #{context.max_attempts} attempts failed")
ErrorNotifier.notify(result.errors, attempts: context.attempt)
end
def call(input)
# ...
end
endWhen a step in a pipeline fails and retries, the behavior depends on the retry outcome:
class PlaceOrderUseCase < SenroUsecaser::Base
organize do
step CreateOrderUseCase
step ChargePaymentUseCase # Has retry_on :gateway_error, attempts: 3
step SendConfirmationUseCase
end
endRetry succeeds: Pipeline continues to the next step normally.
CreateOrder ✓ → ChargePayment ✗ → (retry) → ChargePayment ✓ → SendConfirmation ✓
Retry exhausted: Pipeline fails and rollback is triggered.
CreateOrder ✓ → ChargePayment ✗ → (retry x3) → ChargePayment ✗ (exhausted)
↓
ChargePayment.on_failure
↓
CreateOrder.on_failure (rollback)
retry_on is evaluated first. If retries are exhausted or the error is discarded, on_failure is called:
class ProcessPaymentUseCase < SenroUsecaser::Base
input Input
retry_on :gateway_error, attempts: 3
discard_on :invalid_card
on_failure do |input, result, context|
if context.retried?
# All retries exhausted
logger.error("Payment failed after #{context.attempt} attempts")
else
# First failure (discarded or non-retryable error)
logger.error("Payment failed immediately: #{result.errors.first&.code}")
end
end
def call(input)
# ...
end
endcall(input)
↓
failure ←──────────────────────────────┐
↓ │
retry_on matches? ──yes──→ attempt < max?
↓ no ↓ yes │ no
↓ wait → retry ───┘
↓ ↓
└────────────────────────────┴──→ on_failure
↓
(rollback if pipeline)
Use extend_with to integrate validation libraries like ActiveModel::Validations:
# Define validation extension
module InputValidation
def self.around(input, &block)
# input is the input object passed to call
return block.call unless input.respond_to?(:validate!)
input.validate!
block.call
rescue ActiveModel::ValidationError => e
errors = e.model.errors.map do |error|
SenroUsecaser::Error.new(
code: :validation_error,
field: error.attribute,
message: error.full_message
)
end
SenroUsecaser::Result.failure(*errors)
end
end
module OutputValidation
def self.after(input, result)
return unless result.success?
output = result.value
output.validate! if output.respond_to?(:validate!)
rescue ActiveModel::ValidationError => e
Rails.logger.error("Output validation failed: #{e.message}")
end
end
# Input class with ActiveModel validations
class CreateUserInput
include ActiveModel::Validations
attr_accessor :name, :email
validates :name, presence: true, length: { minimum: 2 }
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
def initialize(name:, email:)
@name = name
@email = email
end
end
# Apply validation to UseCase using input class declaration
class CreateUserUseCase < SenroUsecaser::Base
input CreateUserInput
extend_with InputValidation, OutputValidation
def call(user_input)
# user_input is already validated by InputValidation hook
User.create!(name: user_input.name, email: user_input.email)
end
end
# Usage - pass input object directly
input = CreateUserInput.new(name: "", email: "invalid")
result = CreateUserUseCase.call(input)
result.failure? # => true
result.errors.first.field # => :nameclass RegisterUserUseCase < SenroUsecaser::Base
class Input
#: (name: String, email: String, password: String, **untyped) -> void
def initialize(name:, email:, password:, **_rest)
@name = name
@email = email
@password = password
end
def name = @name
def email = @email
def password = @password
end
# Hooks
extend_with Logging
extend_with TransactionWrapper
input Input
output UserOutput
# Pipeline
organize do
step ValidateUserInputUseCase
step CheckDuplicateEmailUseCase
step CreateUserUseCase
step SendWelcomeEmailUseCase, on_failure: :continue
end
endbundle add senro_usecaserOr add to your Gemfile:
gem "senro_usecaser"class CreateUserUseCase < SenroUsecaser::Base
class Input
#: (name: String, email: String, **untyped) -> void
def initialize(name:, email:, **_rest)
@name = name #: String
@email = email #: String
end
#: () -> String
def name = @name
#: () -> String
def email = @email
end
class Output
#: (user: User) -> void
def initialize(user:)
@user = user #: User
end
#: () -> User
def user = @user
end
input Input
output Output
def call(input)
user = User.create(name: input.name, email: input.email)
success(Output.new(user: user))
rescue ActiveRecord::RecordInvalid => e
failure(SenroUsecaser::Error.new(
code: :validation_error,
message: e.message
))
end
end
input = CreateUserUseCase::Input.new(name: "Taro", email: "taro@example.com")
result = CreateUserUseCase.call(input)
if result.success?
puts "Created user: #{result.value.user.name}"
else
puts "Error: #{result.errors.first.message}"
endclass CreateUserUseCase < SenroUsecaser::Base
depends_on :user_repository, UserRepository
depends_on :event_publisher, EventPublisher
class Input
#: (name: String, email: String, **untyped) -> void
def initialize(name:, email:, **_rest)
@name = name
@email = email
end
def name = @name
def email = @email
end
class Output
#: (user: User) -> void
def initialize(user:)
@user = user
end
def user = @user
end
input Input
output Output
def call(input)
user = user_repository.create(name: input.name, email: input.email)
event_publisher.publish(UserCreated.new(user))
success(Output.new(user: user))
end
end
# Register dependencies
SenroUsecaser.container.register(:user_repository, UserRepository.new)
SenroUsecaser.container.register(:event_publisher, EventPublisher.new)
# Call
input = CreateUserUseCase::Input.new(name: "Taro", email: "taro@example.com")
result = CreateUserUseCase.call(input)SenroUsecaser provides two methods for invoking a UseCase:
.call - Standard invocation. Exceptions are not automatically caught.
result = CreateUserUseCase.call(input)
# If an unhandled exception is raised, it propagates up.call! - Safe invocation. Any StandardError is caught and converted to Result.failure.
result = CreateUserUseCase.call!(input)
# If User.create raises an exception, result is:
# Result.failure(Error.new(code: :exception, message: "...", cause: exception))
if result.failure?
error = result.errors.first
error.code # => :exception
error.message # => Exception message
error.cause # => Original exception object
endUse .call! when you want to ensure all exceptions are captured as Result.failure without explicit rescue blocks in your UseCase.
Type validation errors (from input and output declarations) also follow this pattern:
# With .call - type validation errors raise exceptions
begin
UseCase.call(invalid_input)
rescue ArgumentError => e
puts e.message # "Input SomeClass must include HasUserId"
end
# With .call! - type validation errors become Result.failure
result = UseCase.call!(invalid_input)
result.failure? # => true
result.errors.first.code # => :exception
result.errors.first.message # => "Input SomeClass must include HasUserId"| Validation | Exception type | With .call |
With .call! |
|---|---|---|---|
| Input type | ArgumentError |
Raises | Result.failure |
| Output type | TypeError |
Raises | Result.failure |
When using .call! with organize pipelines, the exception capture behavior is chained to all steps. This is especially useful with on_failure: :collect:
class PlaceOrderUseCase < SenroUsecaser::Base
organize on_failure: :collect do
step ValidateOrderUseCase # Raises exception -> captured as Result.failure
step ChargePaymentUseCase # Raises exception -> captured as Result.failure
step SendEmailUseCase # Returns explicit failure
end
end
result = PlaceOrderUseCase.call!(input)
# All errors (from exceptions and explicit failures) are collected
result.errors # => [exception_error_1, exception_error_2, explicit_error]Behavior comparison:
| Call method | Pipeline step behavior | Exception handling |
|---|---|---|
.call |
Steps use .call |
Exception propagates up |
.call! |
Steps use .call! |
Exception → Result.failure, collected if :collect |
This chaining also applies to nested pipelines:
class InnerUseCase < SenroUsecaser::Base
organize on_failure: :collect do
step StepA # Raises exception
end
end
class OuterUseCase < SenroUsecaser::Base
organize on_failure: :collect do
step InnerUseCase # Inner exception is captured
step StepB # Raises exception
end
end
result = OuterUseCase.call!(input)
result.errors # => [inner_exception_error, step_b_exception_error]By default, if a call method returns a non-Result value, it is automatically wrapped in Result.success. This allows for more concise UseCase implementations.
# Explicit success (traditional style)
class CreateUserUseCase < SenroUsecaser::Base
def call(input)
user = User.create(name: input.name)
success(user) # Explicitly wrap in Result.success
end
end
# Implicit success (concise style)
class CreateUserUseCase < SenroUsecaser::Base
def call(input)
User.create(name: input.name) # Automatically wrapped as Result.success(user)
end
endThis works with any return type:
class GetUserUseCase < SenroUsecaser::Base
def call(id:)
User.find(id) # Returns Result.success(user)
end
end
class ListUsersUseCase < SenroUsecaser::Base
def call(**_args)
User.all.to_a # Returns Result.success([user1, user2, ...])
end
end
class CheckHealthUseCase < SenroUsecaser::Base
def call(**_args)
nil # Returns Result.success(nil)
end
endNote: Explicit failure(...) calls are never wrapped - they remain as Result.failure.
class CreateUserUseCase < SenroUsecaser::Base
def call(input)
return failure(Error.new(code: :invalid, message: "Name required")) if input.name.empty?
User.create(name: input.name) # Implicit success
end
endinput = CreateUserUseCase::Input.new(name: "Taro", email: "taro@example.com")
result = CreateUserUseCase.call(input)
# Check status
result.success? # => true/false
result.failure? # => true/false
# Get value
result.value # => Output or nil
result.value! # => Output or raises error
result.value_or(default) # => Output or default
# Transform
result.map { |output| output.user.name } # => Result[String]
result.and_then { |output| UpdateProfileUseCase.call(user: output.user) } # => Result[...]
# Handle errors
result.errors # => Array[Error]
result.or_else { |errors| handle_errors(errors) }After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/senro_usecaser.
The following features are planned for future releases:
- Parallel execution in organize - Execute multiple steps concurrently within a pipeline for improved performance
- Ruby LSP extension for Container - IDE autocompletion support for dependency injection with Container
- Automatic RBS generation - Auto-generate RBS type definitions for
input,output,call, anddepends_ondeclarations