Once upon a time I was writing a web app that needed to accept notifications of payments. Once it was notified of a payment (via a webhook) it needed to take certain actions to fulfill the purchase.
An overweight controller
Imagine that you're working on this app. The payment notifications come in the form of PayPal-style IPN data. Here's an approximation of the controller action for receiving these notifications:
(Note that this is a Sinatra action, but an equivalent Rails controller action wouldn't be much different.)
post "/ipn" do demand_basic_auth # Record the raw data before we do anything else email = params.fetch("payer_email") { "<MISSING_EMAIL>" } DB[:ipns].insert(email_address: email, data: Sequel.pg_json(params)) # Prepare a single-use product redemption token token = SecureRandom.hex DB[:tokens].insert(email_address: email, value: token) # Prepare a welcome email subject = "Claim your purchase!" redemption_url_template = Addressable::Template.new("#{request.base_url}/redeem?token={token}") email_template = Tilt.new("templates/welcome_email.txt.erb") contact_email = "contact@example.com" redemption_url = redemption_url_template.expand("token" => token) body = email_template.render(Object.new, login_url: redemption_url, contact_email: contact_email) # Send the email Pony.mail(to: email, from: contact_email, subject: subject, body: body, via: :smtp, via_options: settings.email_options) # Report success [202, "Accepted"]
end
For better or for worse, this is how a lot of web application controller actions look, especially when we haven't yet identified all of our domain objects. Cluttered with a mix of domain and infrastructure concerns, and incorporating database access, presentation logic, and business rules.
There are a lot of responsibilities here that could be teased apart. But the application is a small one, and absent a need for frequent updates to IPN-handling functionality, achieving a “perfectly factored” design could be a lot of work for little payoff.
One area we are updating frequently, however, are the routes and controller actions (which in Sinatra are incorporated in a single file). So we'd really like to slim down our controller actions, and move the bulk of their logic somewhere else.
So where could we quickly move this mass of DB/logic/emailing code?
Over the last few years, an answer to this question has emerged in the Ruby web coding community: Service Objects.
Enter the Service Object
Here's the code above, moved into a service object:
class IpnProcessor def process_ipn(params:, redemption_url_template:, email_options:) # Record the raw data before we do anything else email = params.fetch("payer_email") { "<MISSING_EMAIL>" } DB[:ipns].insert(email_address: email, data: Sequel.pg_json(params)) # Prepare a single-use product redemption token token = SecureRandom.hex DB[:tokens].insert(email_address: email, value: token) # Prepare a welcome email subject = "Claim your purchase!" email_template = Tilt.new("templates/welcome_email.txt.erb") contact_email = "contact@example.com" redemption_url = redemption_url_template.expand("token" => token) body = email_template.render(Object.new, login_url: redemption_url, contact_email: contact_email) # Send the email Pony.mail(to: email, from: contact_email, subject: subject, body: body, via: :smtp, via_options: email_options) end
end
And here's what the controller action now looks like:
post "/ipn" do demand_basic_auth redemption_url_template = Addressable::Template.new("#{request.base_url}/redeem?token={token}") IpnProcessor.new.process_ipn( params: params, redemption_url_template: redemption_url_template, email_options: settings.email_options) # Report success [202, "Accepted"]
end
Apart from authentication and the HTTP status return, the only bit of code we've kept out of the service object is the URL template for the product redemption link. We kept that behind because it's a routing concern, and so it belongs in the HTTP-handling layer.
Is this new object justified?
This refactoring has certainly cleaned up the controller action. But was the creation of a new class actually justified?
Take a look at the code invoking our new service object.
IpnProcessor.new.process_ipn
“IPN processor … process IPN”. That seems a little redundant.
This kind of class-name/method-name reiteration is always a red flag for me in terms of domain modeling. Sandi Metz, author of Practical Object-Oriented Design in Ruby, says to first identify the message, and then identify a fitting role to receive that message. Saying that a process_ipn
message should be received by an “IPN processor” seems tautological.
It's a bit of a cheat, in fact. We could say the same for any message: “who should receive the save
message? Why, the Saver
, of course! What about the increment_amount
message? The AmountIncrementer
!”
And let's talk about that name, IpnProcessor
. The class it names handles business logic (among other things). “IPN” is certainly business terminology. But is IpnProcessor
a term in our business domain? Or is this a concept we invented solely to house a method called process_ipn
?
What do we do when we can't come up with an appropriate receiver for a message?
Procedures: Not just for C code
Take a look at the code inside the process_ipn
method:
# Record the raw data before we do anything else email = params.fetch("payer_email") { "<MISSING_EMAIL>" } DB[:ipns].insert(email_address: email, data: Sequel.pg_json(params)) # Prepare a single-use product redemption token token = SecureRandom.hex DB[:tokens].insert(email_address: email, value: token) # Prepare a welcome email subject = "Claim your purchase!" email_template = Tilt.new("templates/welcome_email.txt.erb") contact_email = "contact@example.com" redemption_url = redemption_url_template.expand("token" => token) body = email_template.render(Object.new, login_url: redemption_url, contact_email: contact_email) # Send the email Pony.mail(to: email, from: contact_email, subject: subject, body: body, via: :smtp, via_options: email_options)
end
What can we say about this code?
We can see that it doesn't depend on any state in the IpnProcessor
class.
And we it seems to walk through various steps, grabbing objects from various places, and performing a sequence of actions on those objects.
There's a name for this kind of code: a procedure. Or, in the terminology of Martin Fowler's Patterns of Enterprise Application Architecture, a transaction script.
Where can we put procedures in a Ruby application? Well, in my applications I usually have a module that gives the app code an overall namespace. In this case, the app was called “perkolator”, so the module was named Perkolator
.
Service Object to Procedure
Let's make the process_ipn
method a public module-level method on this module:
module Perkolator def self.process_ipn(params:, redemption_url_template:, email_options:) # ... end
end
And update the controller action:
post "/ipn" do demand_basic_auth redemption_url_template = Addressable::Template.new("#{request.base_url}/redeem?token={token}") Perkolator.process_ipn( params: params, redemption_url_template: redemption_url_template, email_options: settings.email_options) # Report success [202, "Accepted"]
end
This is a pretty small change. Does it actually make any difference?
I believe it does, for a couple of reasons.
The wrong object causes more trouble than no object at all
My yard is dotted with maple seedlings. They are small and easily uprooted. But some are at the forest edge, where they will cause little trouble as they grow larger. Whereas others are in garden plots, where their root systems will eventually threaten retaining walls and other landscaping elements.
An object that handles business logic but doesn't have a well-defined business domain role is like one of these dangerously-located seedlings. Objects tend to grow and accumulate more responsibilities. As Corey Haines puts it, objects are attractors for functionality. And once they mature, objects with confused, ill-defined roles can be some of the hardest to refactor.
Service Objects accumulate invisible inter-service coupling
The object we created above, IpnProcessor
, is likely to eventually share a /services
subdirectory with lots of other Service Objects. And as much as a Service Object is (usually) an attempt to encapsulate logic, no Service Object exists in a vacuum.
For example, at some point we are likely to add a new Service Object to handle the case when a user clicks on the product redemption link sent by the IpnProcessor
. We might call it ProductRedeemer
.
What will these two objects share in common? Well, if nothing else, we know that they will be effectively communicating with each other via the database tokens
table: one object writing new entries, the other reading off entries and marking them as redeemed. Eventually, they might also share the users
table as well.
In effect, these two Service Objects, IpnProcessor
and ProductRedeemer
, will form two steps in a process of product purchase and delivery. But how will that relationship be represented in the codebase?
The answer is: it won't be. A reader of the IpnProcessor
code won't have any clue where the story is continued, unless some kind soul leaves them a comment to guide them.
And this is my larger concern about the proliferation of service objects handling business rules: you can end up with a whole basket full of Service Objects, many with implicit data dependencies between them, representing business workflows that have no explicit representation. In the worst cases, these pseudo-independent objects don't just share database tables, but also pass state to each other as data-blob hashes via the user session or via URL variables.
Domain-Driven Design
As far as I know, the origin of the Service Object idea is Eric Evan's book Domain Driven Design (DDD). Interestingly, DDD deliberately avoids using the term “Service Objects”, instead simply calling them “Services”. In fact, in explaining Services Evans writes: “some concepts from the domain aren't natural to model as objects”.
Some other things DDD has to say about Services are:
- We should try first to find an appropriate domain model object to receive the functionality, before constructing a service.
- Most services should be infrastructure services, such as “send an email”. Business domain services should be rare. This goes directly against some current trends in Ruby web application development, which advocate for every business action having its own Service Object.
- As a corollary, infrastructure-level and domain-level services should be kept separate.
- Domain-level services (e.g. a “transfer funds” service in a banking app) should be named with terminology which is part of a domain's “ubiquitous language”.
- Services should have no persistent state.
All of these guidelines are consistent with representing Services as procedures in a Ruby application.
If it walks like a procedure, and quacks like a procedure…
There is no shame in writing, or extracting procedural code. Especially in the early stages of implementing a new feature, the needed object roles and responsibilities may be unclear. Prematurely factoring code into objects can do more harm than good over the long run.
I would far rather work on an application with a collection of honest procedures, than one in which business workflows and other domain concepts are implicitly split across a directory full of Service Objects. In the latter case, I'd probably wind up de-factoring the Service Objects into procedures before being able to make any major changes.
As Martin Luther put it: “sin boldly”! Don't let shame about maintaining object-oriented purity drive you to make objects from vague or half-formed domain concepts. If what you have is a procedure, let it be what it is, and be done with it!
Want to read more?
This article was extracted from my new, free email course entitled Lies of Object-Oriented Programming in Ruby and Rails. If it has piqued your interest, you can sign up for the full course by clicking here.