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.