Cuprum
Cuprum is a toolkit for defining business logic as a first-class member of your application. It bridges object-oriented and functional programming techniques to provide a structured approach to defining actions, state transitions, and other processes for your application.
How It Works
class LaunchRocket < Cuprum::Command
private
def build_rocket(name:)
return failure(invalid_rocket_error) if name.nil? || name.empty?
Rocket.new(name:)
end
def fuel_rocket(rocket:)
FuelRocket.new.call(fuel: 100.0, rocket:)
end
def invalid_rocket_error
Cuprum::Error.new(
message: "name can't be blank",
type: 'space.rockets.invalid_rocket_error'
)
end
def launch_rocket(rocket)
rocket.launched = true
end
def process(name:)
rocket = step { build_rocket(name:) }
step { fuel_rocket(rocket:) }
step { launch_rocket(rocket:) }
success(rocket)
end
end
The
Documentation for Cuprum is
available online
.
Why Cuprum?
Most application design is focused on the nouns of your application: your data. And that’s good - your data is important! But what’s left out is the verbs of your application, the business logic that ties everything together. You wind up with overloaded models, enormous controllers, or a complex tangle of concerns, helpers, and services that make it difficult to test or reason about your processes.
Cuprum proposes an alternative. It defines a Command
object that wraps a piece of business logic, such as authenticating a user, validating a data structure, or submitting an API request. Here are a few of the reasons that a command is a powerful abstraction:
- Consistency: Commands are always invoked using the
#call
method, and always return a Result
. This makes reasoning about your logic easier, and enables applying more powerful techniques to your logic, such as the factory or strategy patterns.
- Encapsulation: You can reuse the same logic in a controller, an asynchronous job, and in a CLI tool by calling the command. This also makes testing commands much easier, since you don’t have to carefully set up controller requests or parse framework responses.
- Composability: Commands can call other commands, allowing you to build complex logic from simple components. Cuprum provides built-in support for railway-oriented programming using the
step
method.
Cuprum also defines methods for the partial application and validation of command parameters.
Overview
Cuprum defines three core concepts: Commands, Results, and Errors.
Commands
Commands are used to define a process. They can be used to underlie a controller action or asynchonous job, or in place of a helper method or service call.
class LaunchRocket < Cuprum::Command
private
def process(rocket)
if rocket.launched
return failure(rocket_already_launched_error)
end
rocket.launched = true
success(rocket)
end
def rocket_already_launched_error
Cuprum::Error.new(
message: 'rocket already launched',
type: 'space.errors.rocket_already_launched'
)
end
end
Above, we are defining a simple command to launch a rocket. We define a class extending Cuprum::Command
and define the #process
method, which implements our logic. The #failure
and #success
helpers are used to built a Result (see Result, below), either successful or not, which is returned when calling the command.
Rocket = Struct.new(:name, :launched, keyword_init: true)
rocket = Rocket.new(name: 'Hermes I', launched: false)
command = LaunchRocket.new
command.call(rocket)
rocket.launched
#=> true
To call our command, we invoke the #call
method, passing it the parameters we defined for #process
. (Internally, #call
delegates to #process
, as well as ensuring that the returned object is always wrapped in a Result
.)
Results
Calling a Command
will always return an instance of Cuprum::Result
. Each result has three properties:
Result#value
: The value wrapped by the result. May be nil
for failing results.
Result#status
: Either :success
for a passing result, or :failure
for a failing result.
Result#error
: The error for a failing result. Should be an instance of Cuprum::Error
(see Errors, below).
The result also defines #success?
and #failure?
helpers for checking the result status.
rocket = Rocket.new(name: 'Hermes I', launched: false)
command = LaunchRocket.new
# Calling the command with valid parameters.
result = command.call(rocket)
#=> an instance of Cuprum::Result
result.status #=> :success
result.success? #=> true
result.value #=> the rocket
result.error #=> nil
# Calling the command with invalid parameters.
result = command.call(rocket)
#=> an instance of Cuprum::Result
result.status #=> :failure
result.failure? #=> true
result.value #=> nil
result.error
#=> an instance of Cuprum::Error
result.error.message
#=> "rocket already launched"
result.error.type
#=> "space.errors.rocket_already_launched"
Generally speaking, a result with status :success
will have a #value
, while results with status :failure
will have an #error
. However, this is not always the case: for example, a Create or Update action might return the modified object as part of a failed result.
Errors
A failed result should have its #error
property set to an instance of Cuprum::Error
, which provides information about the failure. Each error has two properties:
Error#message
: A human-readable explanation of the failure.
Error#type
: A namespaced, unique identifier for the error.
For larger applications, the recommended practice is to use defined Error subclasses:
module Space
module Errors
class RocketAlreadyLaunched < Cuprum::Error
TYPE = 'space.errors.rocket_already_launched'
def initialize(message: nil)
super(message: message || 'rocket already launched')
end
end
end
end
More complex commands, or commands that use the step
method (see Steps, below) may have multiple associated errors. Use the Error#type
property to identify which failure case should be handled.
Advanced Features
The building blocks of Cuprum (Commands, Results, and Errors) are simple but powerful tools for defining your application logic. On top of those, Cuprum defines some advanced features to reduce boilerplate and handle complex processes, including Middleware, Parameter Validation, and Steps.
Middleware
A middleware command wraps the execution of another command, allowing you to compose functionality without an explicit wrapper command. Because the middleware is responsible for calling the wrapped command, it has control over when that command is called, with what parameters, and how the command result is handled.
class LoggingMiddleware < Cuprum::Command
include Cuprum::Middleware
def initialize(logger)
@logger = logger
end
attr_reader :logger
private
def process(next_command, *arguments, **keywords)
logger.info("Calling command #{next_command.inspect}")
result = super
logger.info("The command returned a result with status #{result.status}")
result
end
end
Middleware can be called directly by calling the middleware command and passing the wrapped command as the first parameter. However, the easiest way to use one or more middleware commands is using Cuprum::Middleware.apply
to generate a pre-wrapped command. When passing multiple middleware commands to Middleware.apply
, the middleware will be called in that order.
logger = Logger.new(StringIO.new)
log_middleware = LoggingMiddleware.new(logger)
is_even = Cuprum::Command.new do |int|
Result.new(status: (int % 0 ? :success : failure))
end
command_with_middleware =
Cuprum::Middleware.apply(command: is_even, middleware: [log_middleware])
command_with_middleware.call(2)
#=> a Cuprum::Result with status: :success
logger.string.include?('The command returned a result with status success')
#=> true
Middleware can also be used to gate or control the execution of the wrapped command. For example, you can use middleware to implement command authorization, returning a failing result if the configured user is not allowed to call the command with those parameters.
Parameter Validation
Cuprum defines a built-in DSL for validating a command’s parameters prior to evaluating the command.
class LaunchRocket < Cuprum::Command
include Cuprum::ParameterValidation
validate :rocket, Rocket
private
def process(rocket)
rocket.launched = true
end
end
result = LaunchRocket.new.call
result.success? #=> false
result.error.class #=> Cuprum::Errors::InvalidParameters
result.error.message
#=> 'invalid parameters for LaunchRocket - rocket is not an instance of Rocket'
If the parameters fail validation, the command will return a failing result with an instance of Cuprum::Errors::InvalidParameters
.
When multiple validations fail, the error will include all failure messages, not just the first:
class PurchaseItem < Cuprum::Command
include Cuprum::ParameterValidation
validate :item_name, :name
validate :qty, Integer, as: 'quantity'
private
def process(item_name:, qty:); end
end
result = PurchaseItem.new.call(item_name: '', qty: 3.14)
result.error.message
#=> "invalid parameters for PurchaseItem - item_name can't be blank, quantity is not an instance of Integer"
Validations are also inherited from parent classes or included modules.
Parameter validations are defined using the .validate
class method, which can be used in one of five ways:
validate :description
calls the #validate_description
method on the command with the value of the :description
parameter. The method should return an error string or array of error strings, or nil
or an empty array if there are no validation errors.
validate :author, using: :author_is_published?
calls the #author_is_published?
method with the value of the :author
parameter. The method should return an error string or array of error strings, or nil
or an empty array if there are no validation errors.
validate(:title) { |title, as: 'title'| }
passes the value of the :title
parameter to the block. The block should return an error string or array of error strings, or nil
or an empty array if there are no validation errors.
validate :name, :presence
calls the #validate_presence
method on the command (if defined) or the #validate_presence tools method with the value of the :name
parameter. For a full list of available validations, see SleepingKingStudios::Tools.
validate :name, String
validates that the value of the :name
parameter is an instance of String
.
Steps
Cuprum uses steps
to implement Railway-Oriented Programming in Ruby.
To quickly summarize a complex topic, Railway-Oriented Programming uses a series of processes with a defined success or failure state (monads for functional languages, Result
s for Cuprum). Those processes are evaluated in order as long as the result continues to be a success, but fails immediately (and halts execution) if any of the results are a failure.
Cuprum implements this using two methods. The #step
method evaluates the given block and handles the returned value.
- If the value is anything but a
Result
, returns the value.
- If the value is a successful
Result
, returns the result’s #value
.
- If the value is a failing
Result
, throws the result to the nearest steps
context.
The #steps
method generates a context for step
calls and evaluates the given block.
- If all of the
step
calls inside the block return successful Result
s, returns the value returned by the block (wrapped in a Result
if the value is not already a Result
).
- If any of the
step
calls return a failing Result
, immediately halts execution of the block. The #steps
method call then returns the failing Result
.
called_steps = []
result = steps do
called_steps << step { success('first step') }
called_steps << step { failure(Cuprum::Error.new(message: 'second step')) }
called_steps << step { success('third step') }
end
called_steps
#=> ['first step']
result.class
#=> Cuprum::Result
result.failure?
#=> true
result.error.message('second step')
In the example above, the first step
block returns a successful result with a value of "first step"
. The step
call, therefore, returns that value, which is appended to the called_steps
array. The second step
block returns a failing result. This immediately halts execution of the steps
block, which then returns the failing result. Because the steps
block is terminated early, the third step
is never called.
Each Cuprum::Command#call
block automatically generates a steps
context, so you can use step
calls directly inside your #process
method without needing to wrap them in a steps
block. Here’s a more practical example of using steps to handle failures:
class LaunchRocket < Cuprum::Command
private
def build_rocket(name:)
return failure(invalid_rocket_error) if name.nil? || name.empty?
Rocket.new(name:)
end
def fuel_rocket(rocket:)
FuelRocket.new.call(fuel: 100.0, rocket:)
end
def invalid_rocket_error
Cuprum::Error.new(
message: "name can't be blank",
type: 'space.rockets.invalid_rocket_error'
)
end
def launch_rocket(rocket)
rocket.launched = true
end
def process(name:)
rocket = step { build_rocket(name:) }
step { fuel_rocket(rocket:) }
step { launch_rocket(rocket:) }
success(rocket)
end
end
Our #process
method defines three steps.
- The first step calls the
#build_rocket
method. If the given name is nil
or empty, this method returns a failing result, immediately halting the #process
call. Note that we’re assigning the value of rocket
to the value returned by step
, and that this is the newly created Rocket
, not a Result
.
- The second step calls the
#fuel_rocket
method, which in turn delegates to another command. We already know that the rocket must be valid (otherwise, the #process
call would have halted), so we don’t need to check the status of the rocket in this method. Presumably, the FuelRocket
can return it’s own errors, which would be passed to the step
and would halt processing accordingly.
- The third step calls the
#launch_rocket
method. We already know that the rocket was successfully created and fueled, so we can simply update it’s status.
As you can see, using #step
s to manage the control flow has two benefits. First, it smooths over the transition from a Result
to a pure Ruby object, as you can see when we assign the value of rocket
. There’s no need to check the Result
status or make an explicit call to Result#value
.
Second, notice what is not written in the above command. There is no conditional logic checking the result of each step, no defensive checks against invalid values being returned by previous steps. As long as failure states are handled by returning a failing result, using step
s ensures that only valid results will be passed to each successive action.