Using Service Objects in Ruby on Rails

Nicholaus Haskins

Nicholaus Haskins on

Using Service Objects in Ruby on Rails

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.{success ?:true, payload: 'some-data'})

If the success is false, it returns an error.{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.

module AppServices class AppSignalApiService require 'httparty' def initialize(params) @endpoint = params[:endpoint] || 'markers' end def call result = HTTParty.get("{appsignal_app_id}/#{@endpoint}.json?token=#{appsignal_api_key}") rescue HTTParty::Error => e{success?: false, error: e}) else{success?: true, payload: result}) end private def appsignal_app_id ENV['APPSIGNAL_APP_ID'] end def appsignal_api_key ENV['APPSIGNAL_API_KEY'] end end end

You would call the file above with{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?

A screenshot of the file directory holding our service objects
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.

A screenshot of the file directory holding our service objects
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.

class SubscriptionsController < ApplicationController def create @subscription = if result ={ subscription_params: { subscription: @subscription, coupon: params[:coupon], token: params[:stripeToken] } }).call if result && result.success? sign_in @subscription.user redirect_to subscribe_welcome_path, success: 'Subscription was successfully created.' else @subscription.destroy redirect_to subscribe_path, danger: "Subscription was created, but there was a problem with the vendor." end else redirect_to subscribe_path, danger:"Error creating subscription." end end end

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:

module AppServices class SubscriptionService def initialize(params) @subscription = params[:subscription_params][:subscription] @token = params[:subscription_params][:token] @plan = @subscription.subscription_plan @user = @subscription.user end def call # create or find customer customer ||={customer_params: {customer:@user, token:@token}}).call if customer && customer.success? subscription ||={subscription_params:{ customer: customer.payload, items:[subscription_items], expand: ['latest_invoice.payment_intent'] }}).call if subscription && subscription.success? @subscription.update_attributes( status: 'active', stripe_id:, expiration: ){success?: true, payload: subscription.payload}) else handle_error(subscription&.error) end else handle_error(customer&.error) end end private attr_reader :plan def subscription_items base_plan end def base_plan [{ plan: plan.stripe_id }] end def handle_error(error){success?: false, error: error}) end end end

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.

  1. 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.
  2. 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:

module AppServices class CustomerService def initialize(params) @user = params[:customer_params][:customer] @token = params[:customer_params][:token] @account = @user.account end def call if @account.stripe_customer_id.present?{success?: true, payload: @account.stripe_customer_id}) else if find_by_email.success? && find_by_email.payload{success?: true, payload: @account.stripe_customer_id}) else create_customer end end end private attr_reader :user, :token, :account def find_by_email result ||={email:}).call handle_result(result) end def create_customer result ||={customer_params:{, source: token}}).call handle_result(result) end def handle_result(result) if result.success? account.update_column(:stripe_customer_id,{success?: true, payload: account.stripe_customer_id}) else{success?: false, error: result&.error}) end end end end

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.

module StripeServices class CreateSubscription def initialize(params) @subscription_params = params[:subscription_params] end def call subscription = Stripe::Subscription.create(@subscription_params) rescue Stripe::StripeError => e{success?: false, error: e}) else{success?: true, payload: subscription}) end end end

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.

module StripeServices class CreateSubscription def initialize(params) @subscription_params = params[:subscription_params] @stripe_account = params[:stripe_account] @stripe_secret_key = params[:stripe_secret_key] ? params[:stripe_secret_key] : (Rails.env.production? ? ENV['STRIPE_LIVE_SECRET_KEY'] : ENV['STRIPE_TEST_SECRET_KEY']) end def call subscription = Stripe::Subscription.create(@subscription_params, account_params) rescue Stripe::StripeError => e{success?: false, error: e}) else{success?: true, payload: subscription}) end private attr_reader :stripe_account, :stripe_secret_key def account_params { api_key: stripe_secret_key, stripe_account: stripe_account, stripe_version: ENV['STRIPE_API_VERSION'] } end end end

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:

def call begin rescue Stripe ::StripeError => e else end end

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%!

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps