simple_command_dispatcher
Overview
simple_command_dispatcher (SCD) allows your Rails or Rails API application to dynamically call backend command services from your Rails controller actions using a flexible, convention-over-configuration approach.
📋 See it in action: Check out the demo application - a Rails API app with tests that demonstrate how to use the gem and its capabilities.
Features
- 🛠️ Convention Over Configuration: Call commands dynamically from controller actions using action routes and parameters
- 🎭 Command Standardization: Optional
CommandCallablemodule for consistent command interfaces with built-in success/failure tracking - 🚀 Dynamic Route-to-Command Mapping: Automatically transforms request paths into Ruby class constants
- 🔄 Intelligent Parameter Handling: Supports Hash, Array, and single object parameters with automatic detection
- 🌐 Flexible Input Formats: Accepts strings, arrays, symbols with various separators and Unicode support
- ⚡ Performance Optimized: Uses Rails' proven camelization methods for fast route-to-constant conversion
- 📦 Lightweight: Minimal dependencies - only ActiveSupport for reliable camelization
Installation
Add this line to your application's Gemfile:
gem 'simple_command_dispatcher'
And then execute:
$ bundle
Or install it yourself as:
$ gem install simple_command_dispatcher
Requirements
- Ruby >= 3.3.0
- Rails (optional, but optimized for Rails applications)
- Rails 8 compatible (tested with ActiveSupport 8.x)
Quick Start
Here's a complete minimal example showing how to use the gem in a Rails controller:
# 1. Configure the gem (optional - uses Rails.logger by default)
# config/initializers/simple_command_dispatcher.rb
SimpleCommandDispatcher.configure do |config|
config.logger = Rails.logger
end
# 2. Create a command
# app/commands/api/v1/authenticate_user.rb
module Api
module V1
class AuthenticateUser
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
user = User.find_by(email: email)
return nil unless user&.authenticate(password)
user
end
private
def initialize(params = {})
@email = params[:email]
@password = params[:password]
end
attr_reader :email, :password
end
end
end
# 3. Call the command from your controller
# app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
def create
command = SimpleCommandDispatcher.call(
command: request.path, # "/api/v1/authenticate_user"
request_params: params
)
if command.success?
render json: { user: command.result }, status: :ok
else
render json: { errors: command.errors }, status: :unauthorized
end
end
end
Basic Usage
Simple Command Dispatch
# Basic command calls - all equivalent
command = SimpleCommandDispatcher.call(
command: '/api/v1/authenticate_user',
# No `command_namespace:` param
request_params: { email: '[email protected]', password: 'secret' }
)
command = SimpleCommandDispatcher.call(
command: :authenticate_user,
command_namespace: '/api/v1',
request_params: { email: '[email protected]', password: 'secret' }
)
command = SimpleCommandDispatcher.call(
command: 'AuthenticateUser',
command_namespace: %w[api v1],
request_params: { email: '[email protected]', password: 'secret' }
)
# With debug logging enabled
command = SimpleCommandDispatcher.call(
command: '/api/v1/authenticate_user',
request_params: { email: '[email protected]', password: 'secret' },
options: { debug: true } # Enables detailed debug logging
)
# All the above will execute: Api::V1::AuthenticateUser.call(email: '[email protected]', password: 'secret')
Command Standardization with CommandCallable
The gem includes a powerful CommandCallable module that standardizes your command classes, providing automatic success/failure tracking, error handling, and a consistent interface. This module is completely optional but highly recommended for building robust, maintainable commands.
The Real Power: Dynamic Command Execution using convention over configuration
Where this gem truly shines is its ability to dynamically execute commands using a convention over configuration approach. Command names and namespacing match controller action routes, making it possible to dynamically execute commands based on controller/action routes and pass arguments dynamically using params.
Here's how it works with a real controller example:
# app/controllers/api/mechs_controller.rb
class Api::MechsController < ApplicationController
before_action :route_request, except: [:destroy, :index]
def index
render json: { mechs: Mech.all }
end
def search
# Action intentionally left empty, routing handled by before_action
end
private
def route_request
command = SimpleCommandDispatcher.call(
command: request.path, # "/api/v1/mechs/search"
# No need to use the `command_namespace` param, since the command namespace
# can be gleaned directly from `command: request.path`.
request_params: params # Full Rails params hash
)
if command.success?
render json: { mechs: command.result }, status: :ok
else
render json: { errors: command.errors }, status: :unprocessable_entity
end
end
end
The Convention: Request path /api/v1/mechs/search automatically maps to command class Api::V1::Mechs::Search.
Alternative approach for handling nested resource routes with dynamic actions:
# Handle routes like: /api/v1/mechs/123/variants/456/update
# Extract resource action and build namespace from nested resources
path_parts = request.path.split("/")
action = path_parts.last # "update"
resource_path = path_parts[0...-1] # ["/api", "v1", "mechs", "123", "variants", "456"]
# Build namespace from resource path, filtering out IDs
namespace_parts = resource_path.select { |part| !part.match?(/^\d+$/) }
command = SimpleCommandDispatcher.call(
command: action, # "update"
command_namespace: namespace_parts, # ["/api", "v1", "mechs", "variants"]
request_params: params.merge(
mech_id: path_parts[4], # "123"
variant_id: path_parts[6] # "456"
)
)
# Calls: Api::V1::Mechs::Variants::Update.call(mech_id: "123", variant_id: "456", ...)
Versioned Command Examples
# app/commands/api/v1/mechs/search.rb
class Api::V1::Mechs::Search
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
# V1 search logic - simple name search
name.present? ? Mech.where("mech_name ILIKE ?", "%#{name}%") : Mech.none
end
private
def initialize(params = {})
@name = params[:name]
end
attr_reader :name
end
# app/commands/api/v2/mechs/search.rb
class Api::V2::Mechs::Search
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
# V2 search logic - comprehensive search using scopes
Mech.by_cost(cost)
.or(Mech.by_introduction_year(introduction_year))
.or(Mech.by_mech_name(mech_name))
.or(Mech.by_tonnage(tonnage))
.or(Mech.by_variant(variant))
end
private
def initialize(params = {})
@cost = params[:cost]
@introduction_year = params[:introduction_year]
@mech_name = params[:mech_name]
@tonnage = params[:tonnage]
@variant = params[:variant]
end
attr_reader :cost, :introduction_year, :mech_name, :tonnage, :variant
end
# app/models/mech.rb (V2 scopes)
class Mech < ApplicationRecord
scope :by_mech_name, ->(name) {
name.present? ? where("mech_name ILIKE ?", "%#{name}%") : none
}
scope :by_variant, ->(variant) {
variant.present? ? where("variant ILIKE ?", "%#{variant}%") : none
}
scope :by_tonnage, ->(tonnage) {
tonnage.present? ? where(tonnage: tonnage) : none
}
scope :by_cost, ->(cost) {
cost.present? ? where(cost: cost) : none
}
scope :by_introduction_year, ->(year) {
year.present? ? where(introduction_year: year) : none
}
end
The Magic: By convention, routes automatically map to commands:
/api/v1/mechs/search→Api::V1::Mechs::Search/api/v2/mechs/search→Api::V2::Mechs::Search
What CommandCallable Provides
When you prepend CommandCallable to your command class, you automatically get:
- Class Method Generation: Automatic
.callclass method that instantiates and calls your command - Result Tracking: Your command's return value is stored in
command.result - Success/Failure Methods:
success?andfailure?methods based on error state - Error Handling: Built-in
errorsobject for consistent error management - Call Tracking: Internal tracking to ensure methods work correctly
Important: The .call class method returns the command instance itself (not the raw result). Access the actual return value via .result:
command = AuthenticateUser.call(email: '[email protected]', password: 'secret')
command.success? # => true/false
command.result # => the actual User object (or whatever your call method returned)
command.errors # => errors collection if any
Best Practice: Make initialize private when using CommandCallable. This enforces the use of the .call class method and ensures proper success/failure tracking. Making initialize private prevents direct instantiation that would bypass CommandCallable's functionality:
class YourCommand
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
# Your logic here
end
private # <- initialize should be private
def initialize(params = {})
@params = params
end
end
# This works (correct pattern):
YourCommand.call(foo: 'bar')
# This raises NoMethodError (prevents bypassing CommandCallable):
YourCommand.new(foo: 'bar')
Convention Over Configuration: Route-to-Command Mapping
The gem automatically transforms route paths into Ruby class constants using intelligent camelization, allowing flexible input formats:
# All of these are equivalent and call: Api::UserSessions::V1::CreateCommand.call
# Lowercase strings with various separators
SimpleCommandDispatcher.call(
command: :create_command,
command_namespace: 'api::user_sessions::v1'
)
# Mixed case array
SimpleCommandDispatcher.call(
command: 'CreateCommand',
command_namespace: ['api', 'UserSessions', 'v1']
)
# Route-like strings (optimized for Rails controllers)
SimpleCommandDispatcher.call(
command: '/create_command',
command_namespace: '/api/user_sessions/v1'
)
# Mixed separators (hyphens, dots, spaces)
SimpleCommandDispatcher.call(
command: 'create-command',
command_namespace: 'api.user-sessions/v1'
)
The transformation handles Unicode characters and removes all whitespace:
# Unicode support
SimpleCommandDispatcher.call(
command: 'café_command',
command_namespace: 'api :: café :: v1' # Spaces are removed
)
# Calls: Api::Café::V1::CaféCommand.call
Dynamic Parameter Handling
The dispatcher intelligently handles different parameter types based on how your command initializer is coded:
# Hash params → keyword arguments
def initialize(name:, email:) # kwargs
# Called with: YourCommand.call(name: 'John', email: '[email protected]')
end
# Hash params → single hash argument
def initialize(params = {}) # single hash
# Called with: YourCommand.call({name: 'John', email: '[email protected]'})
end
# Array params → positional arguments
request_params: ['arg1', 'arg2', 'arg3']
# Called with: YourCommand.call('arg1', 'arg2', 'arg3')
# Single param → single argument
request_params: 'single_value'
# Called with: YourCommand.call('single_value')
Payment Processing Example
# app/commands/api/v1/payments/process.rb
class Api::V1::Payments::Process
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
validate_payment_data
return nil if errors.any?
charge_card
rescue StandardError => e
errors.add(:payment, e.)
nil
end
private
def initialize(params = {})
@amount = params[:amount]
@card_token = params[:card_token]
@user_id = params[:user_id]
end
attr_reader :amount, :card_token, :user_id
def validate_payment_data
errors.add(:amount, 'must be positive') if amount.to_i <= 0
errors.add(:card_token, 'is required') if card_token.blank?
errors.add(:user_id, 'is required') if user_id.blank?
end
def charge_card
PaymentProcessor.charge(
amount: amount,
card_token: card_token,
user_id: user_id
)
end
end
Route: POST /api/v1/payments/process automatically calls Api::V1::Payments::Process.call(params)
Custom Commands
You can create your own command classes without CommandCallable. Just ensure your command responds to the .call class method and returns whatever structure you need. The dispatcher will call your command and return the result - your convention, your rules.
Error Handling
The dispatcher provides specific error classes for different failure scenarios:
begin
command = SimpleCommandDispatcher.call(
command: 'NonExistentCommand',
command_namespace: 'Api::V1'
)
rescue SimpleCommandDispatcher::Errors::InvalidClassConstantError => e
# Command class doesn't exist
puts "Command not found: #{e.}"
rescue SimpleCommandDispatcher::Errors::RequiredClassMethodMissingError => e
# Command class exists but doesn't have a .call method
puts "Invalid command: #{e.}"
rescue ArgumentError => e
# Invalid arguments (empty command, wrong parameter types, etc.)
puts "Invalid arguments: #{e.}"
end
Configuration
The gem can be configured in an initializer:
# config/initializers/simple_command_dispatcher.rb
SimpleCommandDispatcher.configure do |config|
# Configure the logger (defaults to Rails.logger in Rails apps, or Logger.new($stdout) otherwise)
config.logger = Rails.logger
# Or use a custom logger
# config.logger = Logger.new('log/commands.log')
end
Using Configuration in Commands
You can access the configured logger within your commands to add custom logging:
class Api::V1::Payments::Process
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
logger.info("Processing payment for user #{user_id}")
validate_payment_data
return nil if errors.any?
result = charge_card
logger.info("Payment successful: #{result.inspect}")
result
rescue StandardError => e
logger.error("Payment failed: #{e.}")
errors.add(:payment, e.)
nil
end
private
def initialize(params = {})
@amount = params[:amount]
@card_token = params[:card_token]
@user_id = params[:user_id]
end
attr_reader :amount, :card_token, :user_id
def logger
SimpleCommandDispatcher.configuration.logger
end
def validate_payment_data
errors.add(:amount, 'must be positive') if amount.to_i <= 0
errors.add(:card_token, 'is required') if card_token.blank?
errors.add(:user_id, 'is required') if user_id.blank?
end
def charge_card
PaymentProcessor.charge(
amount: amount,
card_token: card_token,
user_id: user_id
)
end
end
Debug Logging
The gem includes built-in debug logging that can be enabled using the debug option. This is useful for debugging command execution flow:
# Enable debug logging for a single command
command = SimpleCommandDispatcher.call(
command: :authenticate_user,
command_namespace: '/api/v1',
request_params: { email: '[email protected]', password: 'secret' },
options: { debug: true }
)
# Debug logging outputs:
# - Begin dispatching command (with command and namespace details)
# - Command to execute (the fully qualified class name)
# - Constantized command (the actual class constant)
# - End dispatching command
Important: Debug logging mode does not skip execution—it still runs your command and returns real results, but with detailed debug output to help you understand what's happening internally.
Configure logging level:
# In your Rails initializer or application setup
SimpleCommandDispatcher.configure do |config|
logger = Logger.new($stdout)
logger.level = Logger::DEBUG # Set appropriate level
config.logger = logger
end
Migration from v3.x
If you're upgrading from v3.x, here are the key changes:
Breaking Changes
- Method signature changed to keyword arguments:
# v3.x (old)
SimpleCommandDispatcher.call(:CreateUser, 'Api::V1', { options }, params)
# v4.x (new)
SimpleCommandDispatcher.call(
command: :CreateUser,
command_namespace: 'Api::V1',
request_params: params
)
- Removed simple_command dependency:
- Commands no longer need to include SimpleCommand
- Commands must implement a
.callclass method - Return value is whatever your command returns (no automatic Result object)
- Removed configuration options:
allow_custom_commandsoption removed (all commands are "custom" now)- Camelization options removed (always enabled)
- Namespace changes:
- Error classes:
SimpleCommand::Dispatcher::Errors::*→SimpleCommandDispatcher::Errors::*
- Error classes:
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/gangelo/simple_command_dispatcher. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Changelog
See CHANGELOG.md for version history and breaking changes.