ruby

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.

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.

1module AppServices
2
3  class AppSignalApiService
4
5    require 'httparty'
6
7    def initialize(params)
8      @endpoint   = params[:endpoint] || 'markers'
9    end
10
11    def call
12      result = HTTParty.get("https://appsignal.com/api/#{appsignal_app_id}/#{@endpoint}.json?token=#{appsignal_api_key}")
13    rescue HTTParty::Error => e
14      OpenStruct.new({success?: false, error: e})
15    else
16      OpenStruct.new({success?: true, payload: result})
17    end
18
19    private
20
21      def appsignal_app_id
22        ENV['APPSIGNAL_APP_ID']
23      end
24
25      def appsignal_api_key
26        ENV['APPSIGNAL_API_KEY']
27      end
28
29  end
30end

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?

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.

1class SubscriptionsController < ApplicationController
2
3  def create
4    @subscription = Subscription.new(subscription_params)
5
6    if @subscription.save
7
8      result = AppServices::SubscriptionService.new({
9        subscription_params: {
10          subscription: @subscription,
11          coupon: params[:coupon],
12          token: params[:stripeToken]
13        }
14      }).call
15
16      if result && result.success?
17        sign_in @subscription.user
18        redirect_to subscribe_welcome_path, success: 'Subscription was successfully created.'
19      else
20        @subscription.destroy
21        redirect_to subscribe_path, danger: "Subscription was created, but there was a problem with the vendor."
22      end
23
24    else
25      redirect_to subscribe_path, danger:"Error creating subscription."
26    end
27  end
28end

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:

1module AppServices
2  class SubscriptionService
3
4    def initialize(params)
5      @subscription     = params[:subscription_params][:subscription]
6      @token            = params[:subscription_params][:token]
7      @plan             = @subscription.subscription_plan
8      @user             = @subscription.user
9    end
10
11    def call
12
13      # create or find customer
14      customer ||= AppServices::StripeCustomerService.new({customer_params: {customer:@user, token:@token}}).call
15
16      if customer && customer.success?
17
18        subscription ||= StripeServices::CreateSubscription.new({subscription_params:{
19          customer: customer.payload,
20          items:[subscription_items],
21          expand: ['latest_invoice.payment_intent']
22        }}).call
23
24        if subscription && subscription.success?
25          @subscription.update_attributes(
26            status: 'active',
27            stripe_id: subscription.payload.id,
28            expiration: Time.at(subscription.payload.current_period_end).to_datetime
29          )
30          OpenStruct.new({success?: true, payload: subscription.payload})
31        else
32          handle_error(subscription&.error)
33        end
34
35      else
36        handle_error(customer&.error)
37      end
38
39    end
40
41    private
42
43      attr_reader :plan
44
45      def subscription_items
46        base_plan
47      end
48
49      def base_plan
50        [{ plan: plan.stripe_id }]
51      end
52
53      def handle_error(error)
54        OpenStruct.new({success?: false, error: error})
55      end
56  end
57end

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:

1module AppServices
2  class CustomerService
3
4    def initialize(params)
5      @user               = params[:customer_params][:customer]
6      @token              = params[:customer_params][:token]
7      @account            = @user.account
8    end
9
10    def call
11      if @account.stripe_customer_id.present?
12        OpenStruct.new({success?: true, payload: @account.stripe_customer_id})
13      else
14        if find_by_email.success? && find_by_email.payload
15          OpenStruct.new({success?: true, payload: @account.stripe_customer_id})
16        else
17          create_customer
18        end
19      end
20    end
21
22    private
23
24      attr_reader :user, :token, :account
25
26      def find_by_email
27        result ||= StripeServices::RetrieveCustomerByEmail.new({email: user.email}).call
28        handle_result(result)
29      end
30
31      def create_customer
32        result ||= StripeServices::CreateCustomer.new({customer_params:{email:user.email, source: token}}).call
33        handle_result(result)
34      end
35
36      def handle_result(result)
37        if result.success?
38          account.update_column(:stripe_customer_id, result.payload.id)
39          OpenStruct.new({success?: true, payload: account.stripe_customer_id})
40        else
41          OpenStruct.new({success?: false, error: result&.error})
42        end
43      end
44
45  end
46end
47

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.

1module StripeServices
2
3  class CreateSubscription
4
5    def initialize(params)
6      @subscription_params = params[:subscription_params]
7    end
8
9    def call
10      subscription = Stripe::Subscription.create(@subscription_params)
11    rescue Stripe::StripeError => e
12      OpenStruct.new({success?: false, error: e})
13    else
14      OpenStruct.new({success?: true, payload: subscription})
15    end
16
17  end
18
19end

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.

1module StripeServices
2
3  class CreateSubscription
4
5    def initialize(params)
6      @subscription_params  = params[:subscription_params]
7      @stripe_account       = params[:stripe_account]
8      @stripe_secret_key    = params[:stripe_secret_key] ? params[:stripe_secret_key] : (Rails.env.production? ? ENV['STRIPE_LIVE_SECRET_KEY'] : ENV['STRIPE_TEST_SECRET_KEY'])
9    end
10
11    def call
12      subscription = Stripe::Subscription.create(@subscription_params, account_params)
13    rescue Stripe::StripeError => e
14      OpenStruct.new({success?: false, error: e})
15    else
16      OpenStruct.new({success?: true, payload: subscription})
17    end
18
19    private
20
21      attr_reader :stripe_account, :stripe_secret_key
22
23      def account_params
24        {
25          api_key: stripe_secret_key,
26          stripe_account: stripe_account,
27          stripe_version: ENV['STRIPE_API_VERSION']
28        }
29      end
30  end
31
32end

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:

1def call
2   begin
3   rescue Stripe ::StripeError  => e
4   else
5   end
6end

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

Share this article

RSS

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