Saying stuff about stuff.

Announcing Operatic

Operatic defines a minimal standard interface to encapsulate your Ruby operations (often referred to as “service objects”). The job of Operatic is to receive input and make it available to the operation, and to gather output and return it via a result object, this leaves you a well-defined space to write the actual code by implementing the #call method. And with so much of the ceremony taken care of it feels like writing a function but in a Ruby wrapping.

Creating an Operatic operation

To define an operation include Operatic and implement the instance method #call, data can be attached to the operation’s #data object:

class SayHello
  include Operatic

  def call
    data[:message] = 'Hello world'
  end
end

The operation is called with the class method .call and a Success/Failure result object is returned with the operation’s data:

result = SayHello.call
result.class     # => Operatic::Success
result.success?  # => true
result.failure?  # => false
result[:message] # => "Hello world"

Data can be passed to the operation via keyword arguments that are set as instance variables and can be made available as methods with a standard Ruby attr_reader:

class SayHello
  include Operatic

  attr_reader :name

  def call
    data[:message] = "Hello #{name}"
  end
end

result = SayHello.call(name: 'Dave')
result.class     # => Operatic::Success
result.success?  # => true
result.failure?  # => false
result[:message] # => "Hello Dave"

An operation can be specifically marked as a success or failure using #success!/#failure! and data can be attached at the same time:

class SayHello
  include Operatic

  attr_reader :name

  def call
    return failure! unless name

    success!(message: "Hello #{name}")
  end
end

result = SayHello.call(name: 'Dave')
result.class     # => Operatic::Success
result.success?  # => true
result.failure?  # => false
result[:message] # => "Hello Dave"

result = SayHello.call
result.class     # => Operatic::Failure
result.success?  # => false
result.failure?  # => true
result[:message] # => nil

Once the operation has completed or its status specifically declared using #success!/#failure! the operation, its result, and its data are frozen and any attempt at modification will raise a FrozenError. This helps to ensure that even a complicated operation with many code paths can only finalise its result once:

class SayHello
  include Operatic

  attr_reader :name

  def call
    success!(message: "Hello #{name}")
    success!(message: "Goodbye #{name}")
  end
end

SayHello.call(name: 'Dave')
# => FrozenError (can't modify frozen SayHello...

Using Operatic in Rails

A Rails controller might use Operatic like this:

class HellosController < ApplicationController
  def create
    result = SayHello.call(name: params[:name])

    if result.success?
      render plain: result[:message]
    else
      render :new
    end
  end
end

A pattern matching future

Where I think Operatic gets interesting is in being able to pattern match against an Operatic::Result (in Ruby 2.7+) allowing you to think of it as a standardised tuple – an array of its result class and data – instead of an object with methods.

Here’s the Rails example rewritten to use pattern matching, I think it’d be fair to describe it as dense (particularly if you’re unfamiliar with pattern matching) though I’d prefer to call it succinct. But it’s doing quite a lot by allowing you to declare both the expected result type and the shape of the data, and to extract certain keys, all at the same time:

class HellosController < ApplicationController
  def create
    case SayHello.call(name: params[:name])
    in [Operatic::Success, { message: }]
      render plain: message
    in [Operatic::Failure, _]
      render :new
    end
  end
end

In summary

Operatic defines a standard interface for your operations and takes care of input and output leaving you to concentrate on writing code.

You can find the source on GitHub.