This article has been modified from its original appearance in Playbook Thirty-nine - A Guide to Shipping Interactive Web Apps with Minimal Tooling, and tailored to fit this guest post for AppSignal.
There’s a lot of functionality that your app needs to handle, but that logic doesn’t necessarily belong in the controller or even the model. Some examples include checking out with a cart, registering for the site, or starting a subscription.
You could include all this logic in the controller, but you’ll keep repeating yourself, calling the same logic in all those places. You could put the logic in a model, but sometimes, you need access to things that are easily available in the controller, like an IP address, or a parameter in a URL. What you need is a service object.
The job of a service object is to encapsulate functionality, execute one service, and provide a single point of failure. Using service objects also prevents developers from having to write the same code over and over again when it’s used in different parts of the application.
A service object is just a Plain Old Ruby Object ("PORO"). It’s just a file that lives under a specific directory. It’s a Ruby class that returns a predictable response. What makes the response predicable is due to three key parts. All service objects should follow the same pattern.
- Has an initialization method with a params argument.
- Has a single public method named call.
- Returns an OpenStruct with a success? and either a payload or an error.
What’s an OpenStruct?
It’s like the brainchild of a class and a hash. You can think of it as a mini-class that can receive arbitrary attributes. In our case, we’re using it as a sort of temporary data structure that handles just two attributes.
If the success is true
, it returns a payload of data.
OpenStruct.new({success ?:true, payload: 'some-data'})
If the success is false
, it returns an error.
OpenStruct.new({success ?:false, error: 'some-error'})
Here’s an example of a service object that reaches out and grabs data from AppSignals new API, which is currently in beta.
You would call the file above with AppServices::AppSignalApiService.new({endpoint: 'markers'}).call
. I make liberal use of OpenStruct to return a predictable response. This is really valuable when it comes to writing tests because all of the logic’s architectural patterns are identical.
What's a Module?
Using modules provide us with name-spacing and prevents colliding with other classes. This means you can use the same method names in all the classes and they won’t clash because they’re under a specific namespace.
Another key part of the module name is how files are organized in our app. Service objects are kept in a services folder in the project. The service object example above, with the module name of AppServices
, falls into the AppServices
folder in the services directory.
I organize my service directory into multiple folders, each containing functionality for a specific part of the application.
For example, the CloudflareServices
directory holds specific service objects for creating and removing subdomains on Cloudflare. The Wistia and Zapier services hold their respective service files.
Organizing your service objects like this yields better predictability when it comes down to implementation, and it’s easy to see at a glance what the app is doing from a 10k-foot view.
Let’s dig into the StripeServices
directory. This directory holds individual service objects for interacting with Stripes API. Again, the only thing these files do is take data from our application and send it to Stripe. If you ever need to update the API call in the StripeService
object that creates a subscription, you only have one place to do that.
All of the logic that collects the data to be sent is done in a separate service object, living in the AppServices
directory. These files gather data from our application and send it off to the corresponding service directory for interfacing with the external API.
Here’s a visual example: let’s assume that we have someone who is starting a new subscription. Everything originates from a controller. Here’s the SubscriptionsController
.
We’ll first create the subscription in-app, and if it’s successful, we send that, the stripeToken, and stuff like the coupon into a file called AppServices::SubscriptionService
.
In the AppServices::SubscriptionService
file, there are several things that need to happen. Here’s that object, before we get into what’s happening:
From a high-level overview, here’s what we’re looking at:
We have to first get the Stripe customer ID so that we can send it to Stripe to create the subscription. That in itself is an entirely separate service object that does a number of things to make this happen.
- We check to see if the
stripe_customer_id
is saved on the user's profile. If it is, we retrieve the customer from Stripe just to ensure that the customer actually exists, then return it in the payload of our OpenStruct. - If the customer does not exist, we create the customer, save the stripe_customer_id, then return it in the payload of the OpenStruct.
Either way, our CustomerService
returns the Stripe customer ID, and it’ll do what’s necessary to make that happen. Here’s that file:
Hopefully, you can begin to see why we structure our logic across multiple service objects. Could you imagine one giant behemoth of a file with all of this logic? No way!
Back to our AppServices::SubscriptionService
file. We now have a customer that we can send to Stripe, which completes the data that we need in order to create the subscription on Stripe.
We’re now ready to call the last service object, the StripeServices::CreateSubscription
file.
Again, StripeServices::CreateSubscription
service object never changes. It has a single responsibility, and that is to take data, send it to Stripe, and either return a success or return the object as a payload.
Pretty simple right? But you’re probably thinking, this small file is overkill. Let’s look at another example of a similar file to the one above, but this time we’ve augmented it for use with a multi-tenant application via Stripe Connect.
Here’s where things get interesting. We’re using Mavenseed as an example here, although this same logic runs on SportKeeper as well. Our multi-tenant app is a single monolith, sharing tables, separated by a site_id column. Each tenant connects to Stripe via Stripe Connect, and we then get a Stripe Account ID to save on the tenant's account.
Using our same Stripe API calls, we can simply pass the Stripe Account of the connected account, and Stripe will perform the API call on behalf of the connected account.
So in a way, our StripeService
object is performing double-duty, along with both the main application and the tenants, to call the same file, but send in different data.
A few technical notes on this file: I could have shared a simpler example, but I really think it’s valuable for you to see how a proper service object is structured, including its responses.
First, the “call” method has a rescue and else statement. This is the same as writing the following:
But Ruby methods automatically begin a block implicitly, so there’s no reason to add the begin and end. This statement reads as, “create the subscription, return an error if there is one, otherwise return the subscription.”
Simple, succinct, and elegant. Ruby is truly a beautiful language and the use of service objects really highlights this.
I hope that you can see the value that service files play in our applications. They provide a very succinct way of organizing our logic that is not only predictable but easily maintainable!
P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!
——
Read this chapter and more by picking up my new book Playbook Thirty-nine - A Guide to Shipping Interactive Web Apps with Minimal Tooling. In this book, I take a top-down approach in covering common patterns and techniques, based solely on my first-hand experience as a solo-developer building and maintaining multiple high-traffic, high-revenue website applications.
Use the coupon code appsignalrocks and save 30%!