-
Notifications
You must be signed in to change notification settings - Fork 0
CommandCallable Module
The CommandCallable module is the magic behind SCD's command standardization.
One line of code that gives your commands superpowers:
prepend SimpleCommandDispatcher::Commands::CommandCallableWithout CommandCallable, you'd need to manually implement:
- ❌ A
.callclass method - ❌ Success/failure tracking
- ❌ Error collection and management
- ❌ Result storage
With CommandCallable:
- ✅ All of the above, automatically
- ✅ Zero boilerplate
- ✅ Just focus on your business logic
When you prepend CommandCallable, it adds methods to your class before your own methods:
class MyCommand
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
"Hello, World!"
end
endNow MyCommand has:
- A
.callclass method (from CommandCallable) - Your
#callinstance method (your code) -
success?,failure?,errors,result(from CommandCallable)
It just works!
Automatically creates a class method that:
- Instantiates your command class
- Calls the instance
callmethod - Tracks execution state
- Returns the command instance
class MyCommand
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
"Result value"
end
private
def initialize(params = {})
@params = params
end
end
# The .call class method is automatically available
command = MyCommand.call(foo: 'bar')Stores the return value from your call method:
class GetUser
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
User.find_by(id: user_id)
end
private
def initialize(params = {})
@user_id = params[:user_id]
end
attr_reader :user_id
end
command = GetUser.call(user_id: 123)
command.result # => #<User id: 123, ...>Tracks whether the command has been executed:
command = MyCommand.new # Direct instantiation (not recommended)
command.instance_variable_get(:@called) # => nil
command.call
command.instance_variable_get(:@called) # => trueThis ensures success? and failure? only work after execution.
Returns an error collection object:
class ValidateEmail
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
errors.add(:email, 'is required') if email.blank?
errors.add(:email, 'is invalid') unless email.match?(/\A[^@]+@[^@]+\z/)
return nil if errors.any?
"Valid!"
end
private
def initialize(params = {})
@email = params[:email]
end
attr_reader :email
end
command = ValidateEmail.call(email: '')
command.errors[:email] # => ['is required', 'is invalid']Returns true if the command was called and has no errors:
command = MyCommand.call
command.success? # => true if errors.empty?Important: Returns false if the command hasn't been called yet.
Returns true if the command was called and has errors:
command = MyCommand.call
command.failure? # => true if errors.any?The errors object is an instance of SimpleCommandDispatcher::Commands::Errors, which inherits from Hash.
# Add a single error
errors.add(:field_name, 'error message')
# Add multiple errors to the same field
errors.add(:email, 'is required')
errors.add(:email, 'is invalid')
# Add a general error (not tied to a field)
errors.add(:base, 'Something went wrong')The add method automatically prevents duplicate messages for the same field:
errors.add(:email, 'is required')
errors.add(:email, 'is required') # Ignored - duplicate
errors[:email] # => ['is required'] (only one entry)errors.add_multiple_errors({
email: ['is required', 'is invalid'],
password: ['is too short']
})This is useful when composing commands:
class CompositeCommand
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
other_command = OtherCommand.call
if other_command.failure?
errors.add_multiple_errors(other_command.errors)
return nil
end
# Continue processing
end
end# Get errors for a specific field
command.errors[:email] # => ['is required', 'is invalid']
# Check if any errors exist
command.errors.any? # => true/false
command.errors.empty? # => true/false
# Get all errors as a hash
command.errors.to_h # => { email: ['is required'], password: ['is too short'] }command.errors.full_messages
# => ['Email is required', 'Email is invalid', 'Password is too short']
# For :base errors, only the message is returned (no field name prefix)
errors.add(:base, 'Operation failed')
command.errors.full_messages # => ['Operation failed']Critical Concept: CommandCallable's .call method returns the command instance, not the raw result.
class MyCommand
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
"This is the result"
end
end
command = MyCommand.call
# command is the MyCommand instance
command.class # => MyCommand
# Access the actual result via .result
command.result # => "This is the result"
# Access success/failure
command.success? # => true
# Access errors
command.errors # => {}This design allows you to check success/failure and access errors on the same object:
command = AuthenticateUser.call(email: 'user@example.com', password: 'wrong')
if command.success?
# Access the result
user = command.result
render json: { user: user }
else
# Access the errors
render json: { errors: command.errors }, status: :unauthorized
endclass CreateUser
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
validate_user_data
return nil if errors.any?
User.create!(email: email, password: password)
end
private
def initialize(params = {})
@email = params[:email]
@password = params[:password]
end
attr_reader :email, :password
def validate_user_data
errors.add(:email, 'is required') if email.blank?
errors.add(:password, 'is required') if password.blank?
end
endclass ProcessOrder
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
validate_inventory
return nil if errors.any?
validate_payment
return nil if errors.any?
validate_shipping
return nil if errors.any?
create_order
end
private
def validate_inventory
errors.add(:product, 'is out of stock') unless product.in_stock?
end
def validate_payment
errors.add(:payment, 'card declined') unless payment_valid?
end
def validate_shipping
errors.add(:address, 'cannot ship to location') unless can_ship_to?(address)
end
def create_order
Order.create!(product: product, user: user, address: address)
end
endclass ChargeCard
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
validate_charge_data
return nil if errors.any?
charge_card
rescue Stripe::CardError => e
errors.add(:card, e.message)
nil
rescue Stripe::InvalidRequestError => e
errors.add(:base, "Invalid request: #{e.message}")
nil
rescue StandardError => e
errors.add(:base, "Unexpected error: #{e.message}")
nil
end
private
def validate_charge_data
errors.add(:amount, 'must be positive') if amount <= 0
errors.add(:card_token, 'is required') if card_token.blank?
end
def charge_card
Stripe::Charge.create(
amount: amount,
currency: 'usd',
source: card_token
)
end
endclass RegisterUserWorkflow
prepend SimpleCommandDispatcher::Commands::CommandCallable
def call
user = create_user
return nil if errors.any?
send_welcome_email(user)
setup_default_settings(user)
user
end
private
def create_user
command = CreateUser.call(email: email, password: password)
if command.failure?
errors.add_multiple_errors(command.errors)
return nil
end
command.result
end
def send_welcome_email(user)
command = SendWelcomeEmail.call(user: user)
errors.add(:email, 'could not be sent') if command.failure?
end
def setup_default_settings(user)
command = CreateDefaultSettings.call(user: user)
errors.add(:settings, 'could not be created') if command.failure?
end
endCommands using CommandCallable are easy to test:
RSpec.describe MyCommand do
describe '#call' do
subject(:command) { described_class.call(params) }
context 'with valid params' do
let(:params) { { email: 'test@example.com' } }
it 'succeeds' do
expect(command.success?).to be true
end
it 'returns the expected result' do
expect(command.result).to eq('expected value')
end
it 'has no errors' do
expect(command.errors).to be_empty
end
end
context 'with invalid params' do
let(:params) { { email: '' } }
it 'fails' do
expect(command.failure?).to be true
end
it 'returns nil' do
expect(command.result).to be_nil
end
it 'includes error messages' do
expect(command.errors[:email]).to include('is required')
end
it 'includes formatted error messages' do
expect(command.errors.full_messages).to include('Email is required')
end
end
end
endYou can create commands without CommandCallable, but you'll need to implement the interface yourself:
class CustomCommand
# No CommandCallable
# Must implement a .call class method
def self.call(*args)
new(*args).call
end
def initialize(params = {})
@params = params
end
def call
# Your logic
# Return whatever you want
"Custom result"
end
endThis gives you complete control but loses the standardized interface.
Sometimes you need to access the command instance directly:
class ComplexCommand
prepend SimpleCommandDispatcher::Commands::CommandCallable
attr_reader :intermediate_value
def call
@intermediate_value = calculate_intermediate
final_result
end
private
def calculate_intermediate
# Complex calculation
42
end
def final_result
intermediate_value * 2
end
end
command = ComplexCommand.call
command.result # => 84
command.intermediate_value # => 42 (accessible because of attr_reader)This is useful for debugging or when you need to access intermediate state.
CommandCallable provides:
-
Automatic
.callclass method - No boilerplate needed -
Result tracking - Access via
command.result -
Success/failure methods -
command.success?andcommand.failure? -
Error collection - Built-in
errorsobject with helpful methods - Standardized interface - All commands work the same way
Best Practices:
- Always prepend
CommandCallablefor consistency - Make
initializeprivate to enforce proper usage - Return
nilwhen validation fails - Use
errors.addfor all validation errors - Test both success and failure cases