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:
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