Taming complex Service Objects with dry-rb
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
withfilled
: X field is required, and it HAS to contain a Y data typerequired
withmaybe
: X field is required, but it CAN BE an empty string or niloptional
withfilled
: X field is optional, but if the key is provided, it HAS to contain the Y data typeoptional
withmaybe
: 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
🎉
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
andrules
method to store their respective blocks that will be passed down to our on-the-fly contract.validator
that will create a newBaseContract
child class. This class will receive theparameters
andrules
and it will do the work of validating the input, coercing data, and running the business rules we imposed.
And our ApplicationObject
where 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?
andsuccess?
- 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 if
s, 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.