Taming complex Service Objects with dry-rb

Taming complex Service Objects with dry-rb
Photo by Adi Goldstein / Unsplash

As your Rails app grows, MVC feels inadequate. Should I put complex business rules in the models or the controllers? Should I just put everything in Concerns? At this point, most developers fall back to good'ol Service Objects: specialized classes with a single goal (register a user, send an email, etc.).

The problem, with time, from my experience, is that most of these classes become a dumping ground for code. Everything goes into the Service Object, they end up with hundreds and hundreds of lines, and people are not sure what they do anymore since these classes acquire more and more responsibilities over time (the user registration service will now also send a welcome email because it feels the right place).

Another problem I see often is the parameters: most of the time, a Service Object looks like this:

class DoStuff
  def self.perform(params = {})
    ...
   end
end

It is unclear what params should be. To precisely see what this method expects, we need to peek at the code and read the method, line by line, to see what we should send, which type it should be, and what is optional and what is not. Yeah, document it helps, but it is pretty easy to forget to update the documentation when you change things.

dry-rb

dry-rb is a set of gems that abstract tasks like validation, data transformation, dependency injection, and even what I like to think, a primitive form of typing with what I'll show today. There are many other libraries with more broad purposes under the dry-rb umbrella, but I will focus today on dry-validation.

Making params a first-class citizen in your Service Object

What we are gonna do now is use the dry-validation gem to transform params from a shy method parameter into a rich DSL-like structure so that everyone who needs to know what to send to our Service Object should do so by simply opening the class and reading the first lines:

class CreateUser < ApplicationObject
  parameters do
    required(:name).filled(:string)
    required(:email).filled(Types::Email)
    required(:age).filled(:integer)
    optional(:phone).maybe(:string)
  end
  
  rules do
    rule(:email) do
      key.failure('Invalid email') unless ValidEmail2::Address.new(value).valid?
    end

    rule(:age) do
      key.failure('Should be over 18') unless value >= 18
    end
  end

  def call
    puts "These are the parameters I got: #{data}"
    Success()
  end
end

Let me break down the parameters and rules for you:

parameters define what kind of keys and values the client should send.

required() indicates that the given key should be presented in the parameters and filled() says that it should contain whatever data type I want (:string, :integer or Types::Email).

optional() indicates that the key is not mandatory and maybe() tells us what kind of data it may contain. You could mix optional() with filled() too. Here is a table of possible mixes:

  • required with filled: X field is required, and it HAS to contain a Y data type
  • required with maybe: X field is required, but it CAN BE an empty string or nil
  • optional with filled: X field is optional, but if the key is provided, it HAS to contain the Y data type
  • optional with maybe: X field is optional, and it MIGHT be empty or nil

rules are the business logic we might want to apply to our data, for example, checking the age, if the email is not already taken, and so on.

In my opinion, this looks much better: clients of this class now know exactly what parameters they should send, what type, what is optional, and what is mandatory. Plus, we have some business rules in place for free.

Here is a bonus: whenever possible, dry-validation will do type coercion, so you could send "18" as age and it is smart enough to coerce it to 18 🎉

💡
I need to tell you that this is not totally my idea. I worked with some really strong Ruby devs and we built this pattern together in the span of a couple of years. Cheers to you, folks!

Let's start with our contract, which is the base class that will be used by our validator:

class BaseContract < Dry::Validation::Contract
  config.messages.load_paths << 'config/errors.yml'
  config.messages.default_locale = 'en' # i18n locale
end

Technically, you can create contracts explicitly, as the documentation shows

class NewUserContract < Dry::Validation::Contract
  params do
    required(:email).value(:string)
    required(:age).value(:integer)
  end
end

and then re-use these contracts, but I like to define the contract "on the fly", by creating a child class from BaseContract and setting up params and rules inside my Service Objects to act as documentation, too. I can do this by creating a module that will be extended by ApplicationObject later on:

module Validation
  include Dry::Monads[:result]

  def call(data = {})
    validate(data).then do |result|
      return Failure(result.errors) if result.failure?

      new(result.to_h).call
    end
  end

  private

  def parameters(&block)
    @parameters = block
  end

  def rules(&block)
    @rules = block
  end

  def validator
    local_parameters = @parameters
    local_rules      = @rules

    @validator ||= Class.new(::BaseContract) do
      params do
        instance_exec(&local_parameters)
      end

      instance_exec(&local_rules) if local_rules
    end.new
  end

  def validate(data)
    validator.call(data.to_h)
  end
end

This module provides several things:

  • the call method that will be our entry point for the service
  • parameters and rules method to store their respective blocks that will be passed down to our on-the-fly contract.
  • validator that will create a new BaseContract child class. This class will receive the parameters and rules and it will do the work of validating the input, coercing data, and running the business rules we imposed.

And our ApplicationObjectwhere our SOs will inherit from:

class ApplicationObject
  extend Validation
  include Dry::Monads[:result]

  def initialize(data)
    @data = data
  end

  def call
    raise NotImplementedError
  end

  private

  attr_reader :data
end

ApplicationObject classes are expected to implement an instance method call to process the data that was validated and coerced by dry-rb and that's pretty much it.

I won't go too far into Dry::Monads but we will be using it to return a Failure or a Success response. You can read more about it here. They are compelling and go far beyond Success and Failure.

With all of this in place, let's invoke our SO and see what happens:

irb(main):022:0> result = Commands::CreateUser.call(name: 'l', email: 'luiz@luiz.com', age: "29")
These are the parameters I got: {:name=>"l", :email=>"luiz@luiz.com", :age=>29}
=> Success()
irb(main):023:0> result.success?
=> true
irb(main):024:0> result.failure?
=> false

# Now, with invalid input

irb(main):019:0> result = Commands::CreateUser.call(name: 'l', email: 'luiz@luiz.com', age: 16)
=> Failure(#<Dry::Validation::MessageSet messages=[#<Dry::Validation::Message text="Should be over 18" path=[:age] meta={}>] options={}>)
irb(main):020:0> result.failure?
=> true
irb(main):021:0> result.success?
=> false

You notice many things:

  • I sent the age as a string, but it was coerced to an integer.
  • when the validation failed, I got a clear response and an object that responded to failure? and success?
  • I'm 100% sure that if the instance method call got invoked is because the data I have is valid and the data abide by the business rules. This is invaluable!

We achieved all of that with not a lot of effort. My call method can be free of any ifs, I don't have to check any data type or make any business rule validation, and I'm confident that the data I have is as valid as possible.

If you pay attention to our parameters definition, you noticed this:

required(:email).filled(Types::Email)

while name and phone are :string, email has this weird type:Types::Email. What is that exactly? Well, I am glad you asked. With dry-types we can create custom data types. I created an email type that will get an email address, and it will downcase, strip, and remove any record separator from the email address:

module Types
  Dry.Types()

  Email = Dry.Types.Constructor(String) do |str|
    str ? str.strip.chomp.downcase : str
  end
end

You can now use Types::Email anywhere.

How faster than ActiveRecord can it be?

Peter Solnica, the maintainer of dry-rb put together a benchmark comparing dry-validation and ActiveModel and it turns out ActiveModel can be 14x slower than dry-validation

What about Sorbet?

On a really lazy way to put it, Sorbet and dry-validation can do very similar things: data typing and some form of validation since Sorbet will raise an error, too, if you send an incorrect data type or return something different than you previously defined.

The thing with Sorbet is that I find it too invasive: you have to annotate every method with Sigils, and it can get ugly if you are expecting many parameters. On top of that, Sorbet can't do coercion (for obvious reasons), and you are still bound to have business rules as "second-class citizens" since you have to check them inside your code, not like dry-validation can do.

Wrapping up

Both legacy and greenfield applications can benefit from it. The overhead of adding dry-rb is negligible, the learning curve is slight, and the benefits are immense: explicit DSLs, data types, validations, and business rules are all clear for everyone in the team, even a person who doesn't know Ruby can probably read the parameters and rules and understand what is going on.

What I just showed is the tip of the iceberg, dry-rb can do so much more that it would take many posts like this to cover everything. One of my personal favorites after dry-validation is dry-rails, especially the Safe Params and ApplicationContract that fits nicely with what we just built.

Icons created by Freepik - Flaticon