julik live

Your minimum viable Rails service pattern

This post is very prescriptive - not because I want to be didactic, but because it offers a number of very small techniques that work for me, and work very well. Take it with a grain of salt, and of course modify at will - but please don't get distracted by the imperative tone. So...

A service fits best in a standalone Module

which is is as close as you can get to a namespaced, yet freestanding function. Services are doers by definition (or Commands, if you will). So don’t make them objects. Don't do this:

payment_processing = PaymentProcessing.new(user, purchase)
payment_processing.process

Note how even the name (a noun called "Processing" - what is this? an abstract processing what exactly?) feels wrong. You are entering the execution in the kingdom of the nouns with this pattern, but moreover - you are opening doors for yourself to perform destructive actions in the PaymentProcessing#initialize. For instance, I once had to deal with a Service object that was actually a Command, but it managed to perform both an SQL UPDATE (destructively touch the database!) and execute an HTTP call to an external service, all of the above in the constructor. In addition to unpredictability, a service like this is hard to test. Instead of having to force yourself to make a service that has a constructor only to fill it up with values, use a module that is a container of functions:

PaymentProcessing.process(user, purchase)

Creating these is very cheap, even if you are using more methods within the module. Module methods are very easy to attach using the extend self idiom:

module PaymentProcessing # "DoingThings" is an acceptable naming convention for a module, better than for an object
  extend self # now each method you define can be called on the module itself

  def process(user, purchase)
     …
     debit(user)
     credit(gateway)
     prepare_invoice(purchase)
     ...
  end

  private
  def debit(user)
    ...
  end

  def prepare_invoice(purchase)
    ...  
  end
end

If you really really want inheritance in this scenario, you can of course always use a class instead. Do not use class variables - limit yourself to method-local variables only - most of threading issues and pretty much all Rails reloading issues go out the window once you do.

When dealing with heavy models, flow control with Exceptions is totally fine

For controllng what happens to your command during execution, follow a very simple branching strategy:

Anything that does not raise an exception is a happy path, anything that does is a deviation.

Situations where there are multiple happy paths are exceptionally rare in my experience, and maybe you need to branch inside the service if you encounter them. But in the basic sense, here is how your controller action will look:

def create
  user = User.find(params.require(:user_id))
  payment = Payment.create!(params.require(:payment))
  PaymentProcessing.process(user, payment)
  render :nothing, status: 201
rescue ActiveRecord::RecordInvalid # when Payment.create! fails...
  # edge case
rescue PaymentProcessing::InsufficientFunds
  # another edge case
rescue PaymentProcessing::UserBlocked
  # and another
rescue PaymentProcessing::UserDataustBeStoredInFreedomostan
  # and another...
rescue PaymentProcessing::PaymentGatewayError
  # and another...
end

This way you get the benefit of pattern matching on return values that functional languages brag about. Do not be misled that Ruby does not have bona-fide matching on the return value - but it doesn’t unfortunately present us with a viable failure on uncaptured result. There is no syntax-level option for a case statement without the default case. Exceptions, however, do give you opportunities for having an explicit failure scenario when you do not take care of the default (catch-all) outcome failure case.

Do not use rescue_from in this pattern, because it separates your branching on the result of the command from the place where the command gets executed. And the Ruby "rescue without begin” idiom is just shorter, and more readable, and less Rails-specific.

.() is a neat trick but you don't need it

Making services callable is possible without.

Do not use the fancy .() method definition, like Trailblazer does. The reason is this: methods like this are a show off - “look what we can do in Ruby”. This stuff is all fine and nice but when you are in a bind trying to figure out why something does not work correctly in your application the last thing you want to be doing is peeling off homegrown syntax sugar like this. If you really want callables, Ruby has a whopping two idioms to help you - having an object respond to call and having it respond to []. You can even turn your service into a Proc and make it usable as an iterator:

module ResetPassword
  extend self

  def call(user)
    …
  end

  def to_proc
     method(:call).to_proc
  end
end

# batch_reset_controller.rb
def reset
  users = User.find(1, 2, 3, 4)
  users.map(&ResetPassword) # Seriously, this works!
rescue SpecialCase
  # with the caveat that you don't know on which User that happened
  ...
end

Having long method signatures in a Service is OK

Do not use “multi-parameter constructors” just to be able to do this:

service = PaymentProcessor.new(user, payment, gateway, mutex, logger, …)
service.perform

If you need 5 arguments to perform a certain operation, just make them the arguments of your callable command. Here is why: for executing the operation you need the caller to conform to a contract. This contract implies the availability of all of the given collaborators (the User, the Mutex, the Logger and whatnot). When executing the operation, you need to check for the presence of all of these parameters. If you move the parameter checks into the constructor of the service, and the execution to the perform method (or whatever method that handles the actual operation you are performing), you are divorcing the contract for the operation from it’s execution - and it is very likely you will make a mistake because they no longer share a unit of scope (and a unit of execution). As a bonus, use keyword arguments while you are at it to exclude mistakes with positional arguments at the outset:

PaymentProcessor.process(user: current_user, gateway: PaymentGateway.default, …)

If you omit a keyword argument it will make Ruby give you clear exceptions on a contract violation. Additionally, if you introduce extra parameters you can plug them with default values in the operation itself:

module PaymentProcessor
  extend self
  def process(user:, gateway:, logger: Rails.logger)
    # gives us a logger, both injectable and default for when we don't care that much
   …
  end
end

Do not be afraid of long keyword argument lists. See them as an extended contract of your Command. If you find them very unwieldy - replace the parameter list with an object that will contain all the keyword arguments. You can use anything that responds to to_hash as a package for keyword arguments:

connection_config = EnterpriseDatabaseConnectionConfig.new
# will call `connection_config.to_hash` to extract a Hash keyed by Symbols. AND you can test
# `MyCompany.....#to_hash` in a separate unit test, as a bonus...
pg_conn = pg.connect(**connection_config)

Interesting read on exceptions versus ActiveRecord here.

When exceptions are actually expensive

Bear in mind that exceptions are expensive due to stack unwinding. So, using exceptions for flow control, like many things, is appropriate where it is appropriate and not appropriate in some other situations - for exampe, in tight loops. Where this really matters, you can use branching on the return value like you would do in "pedestrian" languages with "full-manual" error handling, like C and Go. For instance recent implementations of non-blocking writes in IO etc. use that idiom:

case socket.write_nonblock(bytes)
when :iowait # The socket is clogged, do a pass
   Thread.pass
when Fixnum # the number of bytes written
   …
else # nil, the send has completed or there was nothing to send
end

Bear in mind however, that this is useful for tight loops or IO-intensive scenarios only, since it degrades your control flow to explicit branching on return values. This is indeed (at least in my opinion), not any better than

f, err := file.Open(path)

that you might have in some other languages claiming “explicit error handling” as an extreme fom of virtue.

 
comments powered by Disqus

Aspirine not included.