Logo of AppSignal

Menu

Using Service Objects in Ruby on Rails

Nicholaus Nicholaus Haskins on

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.

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
module AppServices

  class AppSignalApiService

    require 'httparty'

    def initialize(params)
      @endpoint   = params[:endpoint] || 'markers'
    end

    def call
      result = HTTParty.get("https://appsignal.com/api/#{appsignal_app_id}/#{@endpoint}.json?token=#{appsignal_api_key}")
    rescue HTTParty::Error => e
      OpenStruct.new({success?: false, error: e})
    else
      OpenStruct.new({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 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class SubscriptionsController < ApplicationController

  def create
    @subscription = Subscription.new(subscription_params)

    if @subscription.save

      result = AppServices::SubscriptionService.new({
        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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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 ||= AppServices::StripeCustomerService.new({customer_params: {customer:@user, token:@token}}).call

      if customer && customer.success?

        subscription ||= StripeServices::CreateSubscription.new({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: subscription.payload.id,
            expiration: Time.at(subscription.payload.current_period_end).to_datetime
          )
          OpenStruct.new({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)
        OpenStruct.new({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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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?
        OpenStruct.new({success?: true, payload: @account.stripe_customer_id})
      else
        if find_by_email.success? && find_by_email.payload
          OpenStruct.new({success?: true, payload: @account.stripe_customer_id})
        else
          create_customer
        end
      end
    end

    private

      attr_reader :user, :token, :account

      def find_by_email
        result ||= StripeServices::RetrieveCustomerByEmail.new({email: user.email}).call
        handle_result(result)
      end

      def create_customer
        result ||= StripeServices::CreateCustomer.new({customer_params:{email:user.email, source: token}}).call
        handle_result(result)
      end

      def handle_result(result)
        if result.success?
          account.update_column(:stripe_customer_id, result.payload.id)
          OpenStruct.new({success?: true, payload: account.stripe_customer_id})
        else
          OpenStruct.new({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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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
      OpenStruct.new({success?: false, error: e})
    else
      OpenStruct.new({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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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
      OpenStruct.new({success?: false, error: e})
    else
      OpenStruct.new({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:

1
2
3
4
5
6
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%!

10 latest articles

Go back
Ruby magic icon

Subscribe to

Ruby Magic

Magicians never share their secrets. But we do. Sign up for our Ruby Magic email series and receive deep insights about garbage collection, memory allocation, concurrency and much more.

We'd like to set cookies, read why.