Why use ServiceObjects, QueryObjects, etc. rather than Lambdas?

I work on a Rails team in the healthcare industry and I’m currently refactoring a very data-driven application into ServiceObjects, QueryObjects, etc. to keep down the complexity of the logic in my models and controllers. I’m more accustomed to using a (pragmatic) functional/relational approach when developing data-driven applications.

Overall I’m enjoying my experience using Ruby and Rails again after many years of using other technology. But, I’m interested in hearing the opinions of those more experienced than myself. Particularly those who are equally comfortable with functional and object-oriented programming or using Rails for very data-driven applications or both.

Since Ruby supports functional programming, have you found good ways of refactoring complex Rails applications using functional patterns that seem reasonably idiomatic?

Do you have any thoughts on the benefits of using ServiceObjects, QueryObjects, etc. vs just using lambdas?

One of the things I’ve always liked about Ruby/Rails is that they’re pragmatic by design so I’m not really interested in starting a debate over OOP vs FP, just learning from the experience of others and the trade offs you’ve found.

Perhaps an example may help:

# app/services/entry_builder.rb
class EntryBuilder < ApplicationService
  def initialize(log)
    @log = log
  end

  def call(params)
    @log # ... do something with the log and params and return a result
  end
end

# app/models/log.rb
class Log < ApplicationRecord
  ...
  def entry_builder
    @entry_builder ||= EntryBuilder.new(self)
  end

  def create_entry(params)
     entry_builder.call(params)
  end
  ...
end

vs.

# app/functions/entry_builder.rb  ???
EntryBuilder = lambda do |log, params|
  log # ... do something with the log and params and return a result
end.curry

# app/models/log.rb
class Log < ApplicationRecord
   ...
   def entry_builder
     @entry_builder ||= EntryBuilder.call(self)
   end

   def create_entry(attributes)
      entry_builder.call(attributes)
   end
end

# else where ...
EntryBuilder.call(Log.find(1), ....)

Thank you in advance for your help.

4 Likes

The service / query object abstraction is one of code organization. They’re all Ruby classes or modules (or lambdas) at the end of the day.

Lambdas are handy for simple inline functions, but anything more complex should be split into multiple methods to make it easier to understand.

Think the main benefit from using lambdas is that you ensure your function is stateless. Here are a few other patterns to implement EntryBuilder.call(params) without initializing a new object:

class EntryBuilder
  class << self
    def call(log, *params)
      turtles(params[:turtles])
    end
    
    def turtles(turtles)
    end
  end
end

module EntryBuilder
  # class << self pattern can be used here too
  def self.call(log, *params)
  end
end

module EntryBuilder
  extend self

  def call(log, *params)
  end
end


module EntryBuilder
  module_function

  def call(log, *params)
  end
end
3 Likes

I see two situations in whic using ServiceObjects and similar abstractions makes sense:

  1. In many cases, the work that is performed is rather a lot, so a ‘single’ function would be long and unreadable. Thus, we need to split it up.
  2. In certain circumstances it makes sense to separate the configuration of the thing that will be called from the actual calling of it, as well as possibly making the configuration more explicit. For instance, we might want to configure once (which might do some expensive setup logic) and call often (which now does not need to repeat the expensive setup).

(1) can often be just abstracted by having a separate module with functions (of which only e.g. a single one is a public module_function). We can then just call MyModule.do_the_thing(some, params).

(2) is more complicated and I think this is where the more ‘classical’ approach such as the example you have in your original post make sense.

2 Likes

Thank you @ferngus and @Qqwy for your replies, some good food for thought.

@Qqwy, Yes that’s the motivation for me to use “Command Objects” (Service Objects / Query Objects). I’m refactoring a data-driven system where there are some fairly involved data transformations, and my models and to some extent my controllers were really getting out of hand. Refactoring them into Service Objects and Query Objects has lead to a much more pleasing result. So I’m not in any way trying to poo-poo the pattern (perhaps my title was a bit of click-bait).

But, coming from a functional programming background it just kind of glares at me that, as you said @ferngus, they’re just lambdas. I wanted to consider the possibility of using lambdas (or providing a proc-like interface) since this is a facility that Ruby provides in a way that would be clean and seem idiomatic in a Rails project. So that I can take advantage of the other properties of lambdas (composition, currying, memoization, etc.). While not encouraging the newer developers on my team down a path that would lead to code that is weird (unless it was truly necessary :slightly_smiling_face:).

My first attempt at this (and what I’m currently using) is that I implemented an Invokable module attempting to mirror the Enumerable module that provides a proc-like interface for any object implementing call (see https://github.com/delonnewman/invokable).

But, as I’m looking at the code I’ve written so far. The other thing that is glaring out at me is that when I want to enclose some state in my service object, by passing it to new. I’m just creating a “curried” lambda. A feature that Ruby supports beautifully. Some of your suggestions @ferngus have given me some ideas about how I can perhaps resolve this.

class EntryBuilder
   include Invokable::Command   

   def initialize(log)
     @log = log
   end

   def call(attributes)
     # invoke complex logic
   end
end

EntryBuilder.call(Log.find(1), attributes) # => #<Entry ...>
EntryBuilder.call(Log.find(1)) # => #<EntryBuilder ...>

Thank you again for your thoughtful replies.

1 Like

So I ended up going with:

class EntryBuilder
   include Invokable::Command   

   enclose do |log|
     @log = log
   end

   def call(attributes)
     # invoke complex logic
   end
end

EntryBuilder.call(Log.find(1), attributes) # => #<Entry ...>
EntryBuilder.call(Log.find(1)) # => #<EntryBuilder ...>

Since initialize seems to always return a -1 arity. Any feedback is welcomed, you can see my code here: https://github.com/delonnewman/invokable.

Ruby allows us to pass almost any message to an object, which gives us a tremendous amount of expressiveness. This comes at the cost of weak type checking, which I suspect will negate the referential integrity benefit of implementing higher order functional patterns (composition, currying, etc).

Another common pattern for service objects:

class EntryBuilder
  # common case - EntryBuilder.call(@log, attributes)
  def self.call(log, attributes)
    new(log, attributes).call
  end
  
  # useful for specific configuration / testing - EntryBuilder.new(@log, attributes).call
  def initialize(log, attributes)
    @log, @attributes = log, attributes
  end
  
  def call
  end
end

For me, the benefit isn’t so much referential transparency as it is generality. This approach allows me to use a service object any place I would a proc. For example:

class FilterRecords
  include Invokable::Command
  
  enclose :filter

  def call(records)
    # filter records
  end
end

class SortRecords
  include Invokable::Command
  
  enclose :sort_by, :direction
  
  def call(records)
    # sort records
  end
end

sort_by_name = SortRecords.call(:name, :asc) # new works also
filter_by_date = FilterRecords.call(created_at: Date.today)

(sort_by_name << filter_by_date).call(Person.all) # sorts then filters the records

Along with composition you get memoization, very useful for slow (cacheable) services. You can pass it to any method that takes a block (e.g. map, reduce and select), and more.

chunked_records.map(&sort_by_name)

This works much the same way as including Enumerable into a class that implements “each” allows you to treat it’s instances as a collection.

1 Like

Since Ruby supports functional programming, have you found good ways of refactoring complex Rails applications using functional patterns that seem reasonably idiomatic?

Yes. I’ve been trending in a much more FP-oriented style for Command objects in past couple years (I don’t use the term “service object” because I feel it is too ambiguous).

This is a huge topic so I will try to keep my examples as brief as I can, if you want further clarification let me know.

The pattern I use is built up from several different libraries in the Dry-rb family.

Runtime type-checking

Based on dry-types

module T
  include Dry::Types(default: :strict)

  Callable = T::Interface(:call)
  Procable = T::Interface(:to_proc)
end

Dry::Type objects encapsulate most common kinds of runtime checks you would write imperatively as a type object. This becomes very useful later because all the dry libraries integrate with them.

One of the most useful features is composition

T::User = T::Instance(User)
T::Organization = T::Instance(Organization)
T::Account = T::User | T::Organization

Monads

Using the dry-monads library.

This is a critical piece and if you use nothing else I recommend this one. It makes composing Command objects very simple because they all speak the same result types. (I assume you’re already broadly familiar with the power of these if you’ve done FP)

def find_user(user_id)
  Maybe(User.find_by(id: user_id))
end

def find_address(address_id)
  Maybe(Address.find_by(id: address_id))
end

def assign_user_to_address(user_id, address_id)
  find_user(user_id).fmap do |user|
    find_address(address_id).bind do |address|
      user.update(address: address)
    end
  end
end

do-notation makes this significantly cleaner

class Command
  include Dry::Monads[:result, :maybe]
  include Dry::Monads::Do.for(:call)

  class << self
    def call(*args)
      new.call(*args)
    end
  end

  def call(*)
    raise NotImplementedError
  end
end

class AssignUserToAddress < Command
  def call(user_id, address_id)
    user = yield find_user(user_id)
    address = yield find_address(address_id)

    user.update(address: address)

    Success(user)
  end

  private

  def find_user(id)
    Maybe(User.find_by(id: id))
  end

  def find_address(id)
    Maybe(Address.find_by(id: id))
  end
end

Dependency Injection

This brings in a more OOP concept but I think it’s really useful here. dry-initializer gives us a type-aware DSL for defining initialize setup.

class Command
  extend Dry::Initializer
  include Dry::Monads[:result, :maybe]
  include Dry::Monads::Do.for(:call)

  # ...etc
end

class AssignUserToAddress < Command
  option :find_user, T::Callable, default: -> { User.method(:find) }
  option :find_address, T::Callable, default: -> { Address.method(:find) }

  def call(user_id, address_id)
    user = yield find_user.(user_id)
    address = yield find_address.(address_id)

    user.update(address: address)

    Success(user)
  end
end

Generally I try to make injected dependencies simple callable objects because then I can replace them with lambda test doubles.

This makes the class a sort of IoC container which gives you much more control over the execution environment of the command itself.

You may also use Dry::Type objects as monads for do-notation if you enable the extension.

def call(user_id, address_id)
  yield T::Integer.try(user_id)
  yield T::Integer.try(address_id)

  # ...etc
end

This gives you a Command subclass that is effectively a function. call is the only external interface so it may be easily replaced with another command or a lambda wherever it is used. All Commands use monads as result types so they can easily be combined together.

But inside the Command, you can organize your code however you like. You can have many collaborator objects, you can have lots of private methods, whatever makes sense.

The call function is the driver code and do-notation makes it easy to read. Top-bottom is the happy path, yield represents the failure points where it will halt execution.

Pattern Matching

Finally, we pattern match with dry-matcher

I’m more ambivalent about recommending this, since Ruby 2.7’s native pattern matching basically supplants it. Here is what our matching looks like today

class Command
  extend Dry::Initializer
  include Dry::Monads[:result, :maybe]
  include Dry::Monads::Do.for(:call)

  class << self
    def call(*args, &block)
      result = new.(*args)

      if block_given?
        Dry::Matcher::ResultMatcher.(result, &block)
      else
        result
      end
    end
  end
end

I follow a pattern of always returning Failure results as tuples, with a symbolic name as the first value. This makes matching specific failures much easier.

AssignUserToAddress.(params[:user_id], params[:address_id]) do |on|
  on.success do |user|
    # do something with user
  end

  on.failure :user_missing do |_, user_id|
    render status: :not_found, text: "Could not find user: #{user_id}"
  end

  on.failure :address_missing do |_, address_id|
    render status: :not_found, text: "Could not find address: #{address_id}"
  end

  on.failure do
    render status: :internal_server_error, text: "Unknown error occurred"
  end
end

Ruby 2.7’s case..in syntax basically makes that obsolete

case AssignUserToAddress.(params[:user_id], params[:address_id])
in Success(user)
  # do something with user
in Failure[:user_missing, user_id]
  render status: :not_found, text: "Could not find user: #{user_id}"
in Failure[:address_missing, address_id]
  render status: :not_found, text: "Could not find address: #{address_id}"
else
  render status: :internal_server_error, text: "Unknown error occurred"
end

I highly recommend this pattern for building complex business rules. I’ve been using this in production for the past couple years, and the pattern has proven itself many times over.

Not mentioned but also highly recommended: dry-schema and dry-validation are very nice validation tools that blow strong-params out of the water.

dry-struct is a very good type-aware immutable struct system.

There are some downsides:

Documentation on the dry family is patchy. There’s a lot of stuff that’s only documented in the CHANGELOGs. You’ll be reading some sourcecode. They do have web documentation and places to ask questions like a Discourse forum and a chat system.

The syntax of dry-monads in particular is pretty weird if you’re not used to Haskell or Scala.

I imagine some nitpickers will object to the performance characteristics of some of my examples but in practice I really don’t think it matters unless you’re doing ROFLscale.

Not every Dry library is the same level of attention or quality. Steer clear of really new, experimental libraries. They will change out from under you. The stuff I’m recommending here is pretty stable.

I was using dry-transaction originally and then they decided do-notation was better and EOL’d it. In their defense, I agree with the decision, monads with do-notation is much better.

4 Likes

Thanks for your reply @Adam_Lassek. I’ve been looking at the dry-rb libraries also, and I like what they’re doing. I’ve also had a related discussion with some of the dry-rb developers here: https://discourse.dry-rb.org/t/module-that-allows-you-to-treat-any-object-or-class-as-a-function-any-thoughts/1103.

Oh! I’ve actually read that thread but I didn’t connect the username to this post.

Thanks for sharing this, super helpful