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, Results 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 Results, 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 #steps 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 steps ensures that only valid results will be passed to each successive action.