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.