Cuprum

An opinionated implementation of the Command pattern for Ruby applications. Cuprum wraps your business logic in a consistent, object-oriented interface and features status and error management, composability and control flow management.

Command Steps

Separating out business logic into commands is a powerful tool, but it does come with some overhead, particularly when checking whether a result is passing, or when converting between results and values. When a process has many steps, each of which can fail or return a value, this can result in a lot of boilerplate.

The solution Cuprum provides is the #step method, which calls either a named method or a given block. If the result of the block or method is passing, then the #step method returns the value of the result.

triple_command = Cuprum::Command.new { |i| success(3 * i) }

int = 2
int = step { triple_command.call(int) } #=> returns 6
int = step { triple_command.call(int) } #=> returns 18

Notice that in each step, we are returning the value of the result from #step, not the result itself. This means we do not need explicit calls to the #value method.

Of course, not all commands return a passing result. If the result of the block or method is failing, then #step will throw :cuprum_failed_result and the result, immediately halting the execution chain. If the #step method is used inside a command definition (or inside a #steps block; see below), that symbol will be caught and the failing result returned by #call.

divide_command = Cuprum::Command.new do |dividend, divisor|
  return failure('divide by zero') if divisor.zero?

  success(dividend / divisor)
end

value = step { divide_command.call(10, 5) } #=> returns 2
value = step { divide_command.call(2, 0) }  #=> throws :cuprum_failed_result

Here, the divide_command can either return a passing result (if the divisor is not zero) or a failing result (if the divisor is zero). When wrapped in a #step, the failing result is then thrown, halting execution.

This is important when using a sequence of steps. Let’s consider a case study - reserving a book from the library. This entails several steps, each of which could potentially fail:

Using #step, as soon as one of the subtasks fails then the command will immediately return the failed value. This prevents us from hitting later subtasks with invalid data, it returns the actual failing result for analytics and for displaying a useful error message to the user, and it avoids the overhead (and the boilerplate) of exception-based failure handling.

class CheckUserStatus < Cuprum::Command; end

class CreateBookReservation < Cuprum::Command; end

class FindBookByTitle < Cuprum::Command; end

class ReserveBookByTitle < Cuprum::Command
  private

  def process(title:, user:)
    # If CheckUserStatus fails, #process will immediately return that result.
    # For this step, we already have the user, so we don't need to use the
    # result value.
    step { CheckUserStatus.new.call(user) }

    # Here, we are looking up the requested title. In this case, we will need
    # the book object, so we save it as a variable. Notice that we don't need
    # an explicit #value call - #step handles that for us.
    book = step { FindBookByTitle.new.call(title) }

    # Finally, we want to reserve the book. Since this is the last subtask, we
    # don't strictly need to use #step. However, it's good practice, especially
    # if we might need to add more steps to the command in the future.
    step { CreateBookReservation.new.call(book: book, user: user) }
  end
end

First, our user may not have borrowing privileges. In this case, CheckUserStatus will fail, and neither of the subsequent steps will be called. The #call method will return the failing result from CheckUserStatus.

result = ReserveBookByTitle.new.call(
  title: 'The C Programming Language',
  user:  'Ed Dillinger'
)
result.class    #=> Cuprum::Result
result.success? #=> false
result.error    #=> 'not authorized to reserve book'

Second, our user may be valid but our requested title may not exist in the system. In this case, FindBookByTitle will fail, and the final step will not be called. The #call method will return the failing result from FindBookByTitle.

result = ReserveBookByTitle.new.call(
  title: 'Using GOTO For Fun And Profit',
  user:  'Alan Bradley'
)
result.class    #=> Cuprum::Result
result.success? #=> false
result.error    #=> 'title not found'

Third, our user and book may be valid, but all of the copies are checked out. In this case, each of the steps will be called, and the #call method will return the failing result from CreateBookReservation.

result = ReserveBookByTitle.new.call(
  title: 'Design Patterns: Elements of Reusable Object-Oriented Software',
  user:  'Alan Bradley'
)
result.class    #=> Cuprum::Result
result.success? #=> false
result.error    #=> 'no copies available'

Finally, if each of the steps succeeds, the #call method will return the result of the final step.

result = ReserveBookByTitle.new.call(
  title: 'The C Programming Language',
  user:  'Alan Bradley'
)
result.class    #=> Cuprum::Result
result.success? #=> true
result.value    #=> an instance of BookReservation

Using Steps Outside Of Commands

Steps can also be used outside of a command. For example, a controller action might define a sequence of steps to run when the corresponding endpoint is called.

To use steps outside of a command, include the Cuprum::Steps module. Then, each sequence of steps should be wrapped in a #steps block as follows:

steps do
  step { check_something }

  obj = step { find_something }

  step :do_something, with: obj
end

Each step will be executed in sequence until a failing result is returned by the block or method. The #steps block will return that failing result. If no step returns a failing result, then the return value of the block will be wrapped in a result and returned by #steps.

Let’s consider the example of a controller action for creating a new resource. This would have several steps, each of which can fail:

class BooksController
  include Cuprum::Steps

  def create
    attributes = params[:books]
    result     = steps do
      @book = step :build_book, attributes

      step :run_validations, @book

      step :persist_book, book
    end

    result.success ? redirect_to(@book) : render(:edit)
  end

  private

  def build_book(attributes)
    success(Book.new(attributes))
  rescue InvalidAttributes
    failure('attributes are invalid')
  end

  def persist_book(book)
    book.save ? success(book) : failure('unable to persist book')
  end

  def run_validations(book)
    book.valid? ? success : failure('book is invalid')
  end
end

A few things to note about this example. First, we have a couple of examples of wrapping existing code in a result, both by rescuing exceptions (in #build_book) or by checking a returned status (in #persist_book). Second, note that each of our helper methods can be reused in other controller actions. For even more encapsulation and reusability, the next step might be to convert those methods to commands of their own.

You can define even more complex logic by defining multiple #steps blocks. Each block represents a series of tasks that will terminate on the first failure. Steps blocks can even be nested in one another, or inside a #process method.


Back to Documentation | Versions | 1.0 | Commands