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 Operatic::Result object:

class SayHello
  include Operatic

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

The operation is called with the class method .call and the result object is returned with its attached data, the result is either a #success? or a #failure?:

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

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

class SayHello
  include Operatic

  attr_reader :name

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

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

An operation can specifically mark its result as a success or failure using #success!/#failure! (which are convenience delegates to the Operatic::Result’s counterparts) and attach data 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.success?  # => true
result.failure?  # => false
result[:message] # => "Hello Dave"

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

Once the result is returned or has been marked as a success/failure it’s frozen and any further 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:

result = Operatic::Result.new
result.success!
result.success!
# => #<FrozenError: can't modify frozen Operatic::Result: #<Operatic::Result:0x00000001130c0710 @data={}, @success=true>>

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 success status 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 status 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 [true, { message: }]
      render plain: message
    in [false, _]
      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.