Smarter Rails Services with Active Record Modules

MVC is Not Enough!

We're familiar with the MVC (Model-View-Controller) pattern that Rails offers us––our models map to database tables and wrap our data in objects; controllers receive requests for data and serve up data to the views; views present the data. A common analogy is that of a restaurant––the models are the food, the controller is the waiter taking your order and brining it to you and the view is the beautifully decorated table where you consume your meal.

MVC is a powerful pattern for designing web applications. It helps us put code in its place and build well-architected systems. But we know that MVC doesn't provide a framework for all of the responsibilities and functions of a large-scale web app.

For example, let's say you have a web app for a successful online store where you sell, I don't know, let's say something fun like time travel devices (Doctor Who's new season is coming back really soon, sorry but that's where my head is at).

source

When someone "checks out" and enacts a purchase, there is quite a bit of work your app has to complete. You'll need to:

  • Identify the user who is making the purchase
  • Identify and validate their payment method
  • Enact or complete the purchase
  • Create and send the user a confirmation/receipt email

Let's take it a step further and say that our purchase creation actually occurs via an API endpoint: /api/purchases. So the end result of our purchase handling will involve serializing some data. Now our list of responsibilities also includes:

  • Serialize completed purchase data.

That is a lot of responsibility! Where does all of that business logic rightly belong?

Not in the controller, whose only responsibility it is to receive a request and fetch data. Not in the model, whose only job iswrapping data from the database, and certainly not in the view, whose responsibility it is to present data to the user.

Enter, the service object.

Service Objects to the Rescue

A service object is a simple PORO class (plain old ruby object) designed to wrap up complex business logic in your Rails application. We can call on a service within our controller so that our PurchasesController will look super sexy:

# app/controllers/purchases_controller.rb  
class PurchasesController < ApplicationController  
  def create
    PurchaseHandler.execute(purchase_params, current_user)
  end
end  
I know, beautiful, right? What happens though when our service classes need to get a little smarter, a little less "plain"? Think about the list of responsibilities for our purchase handling above. That list includes *validating* some data (did we find the correct user? does this user have valid payment methods associated to their account?), *serializing* some data (to be returned via our API endpoint) and enacting some post-processing *after* the purchase is enacted (sending the confirmation/invoice email). #### Sometimes a PORO Service Just Won't Cut It While it is absolutely possibly to handle all of these responsibilities in a PORO, it is also true that Rails already offers a powerful set of tools for some of these exact *validating, serializing, post-processing* scenarios. Does any of that sound familiar (I'll give you hint, read the title of this post)...Active Record! Active Record provides us with tools for validation, serialization and callbacks to fire certain methods _after_ a particular method has been called. We're most familiar with the Active Record tool box via the code made available to us by inheriting our models from `ActiveRecord::Base`. You might guess that that's what we'll do with our `PurchaseHandler` service. You'd be wrong. We *don't* want *all* of the tools that Active Record provides––we don't want to persist instances of our `PurchaseHandler` class to our database, and many of Active Record's modules deal with that interaction. Our service class is not a model. It is still very much a service whose job it is to wrap up business logic for enacting a purchase. Instead, we will pick and choose the specific Active Record modules that offer the tools we're interested in, include those modules in our service class, and leverage the code provided by them to meet our specific needs. Let's get started! ### Defining the `PurchaseHandler` Service First things first, let's map out the basic structure of our service class. Then we'll tackle including our Active Record modules. Okay, so, our service's API is super-simple. It looks like this:
PurchaseHandler.execute(purchase_params, user_id)  
Where `purchase_params` looks something like this:
{
  products: [
    {id: 1, name: "Tardis", quantity: 1},
    {id: 2, name: "De Lorean", quantity: 2}
  ],
  payment_method: {
    type: "credit_card",
    last_four_digits: "1111"
  }
}
So, our `PurchaseHandler` class will expose the following method:
# app/services  
class PurchaseHandler  
  def self.execute(params, user_id)
    # do the things
  end
end  
Active Record modules will give us access to validation, serialization and callback hooks, all of which are available on _instances_ of a class. So, our `.execute` class method will need to initialize an _instance_ of `PurchaseHandler`. I'm a fan of keeping the public API really simple––one class method––and using the `.tap` method to keep that one public-facing class method really clean.
#app/services  
class PurchaseHandler  
  def self.execute(params, user_id)
    self.new(params, user_id).tap do |handler|
      handler.create_purchase
    end
  end
end  
The `#tap` method is really neat––it yields the instance that it was called on to a block, and returns the instance that it was called on after the block runs. This means that our `.execute` method will return our `handler` instance, which we can then serialize in the controller (more on that later). Now that we have the beginnings of our service class built out, let's start pulling in the Active Record tools we need to validate the handler. ### Active Record Validations What kind of validations does our service class need? Let's say that before we try to process or complete a purchase, we want to validate that: * The given user exists. * The user has a valid payment method that matches the given payment method. * The products to be purchases are in-stock in the requested quantities. If any of these validations fail, we want to add an error to the handler instance's collection of errors. The `ActiveModel::Validations` module will give us access to Active Record validation methods *and*, via it's own inclusion of the `ActiveModel::Errors` module, it will give us access to an `.errors` attr_accessor and object. We'll use the `#initialize` method to set up the data we need to perform our purchase: * Assigning the user * Assigning the purchase method (i.e. the user's credit card) * Assigning the products to be purchased And we'll run our validations on this data *after* the `#initialize` method runs. (Does that smell like callbacks? Yes!) Let's build out our validations one-by-one. Then we'll write the code to call our validations with the help of Active Record's callbacks. First, we want to validate that we were able to find a user with the given ID.
# app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  validates :user, presence: true

  def self.execute(params, user_id)
    self.new(params, user_id).tap do |handler|
      handler.create_purchase
    end
  end

  def initialize(params, user_id)
    @user = User.find(user_id)
  end
end  
Next up, we'll want to validate that we were able to find a credit card for that user that matches the given card. *Note: Our credit-card-finding code is a little basic. We're simply using the user's ID and the last four digits of the card, included in the params. Keep in mind this is just a simplified example*.
# app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  validates :user, presence: true
  validates :credit_card, presence: true
  validates :products, presence: true

  attr_reader :user, :credit_card, :product_params, 
    :products, :purchase

  def self.execute(params, user_id)
    self.new(params, user_id).tap do |handler|
      handler.create_purchase
    end
  end

  def initialize(params, user_id)
    @user = User.find(user_id)
    @payment_method = CreditCard.find_by(
      user_id: user_id, 
      last_four_cc: params[:last_four_cc]
    )
  end
end  
Lastly, we'll grab the products to be purchased:
#app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  validates :user, presence: true
  validates :credit_card, presence: true
  validates :products, presence: true

  attr_reader :user, :credit_card, :product_params, 
    :products, :purchase
  def self.execute(params, user_id)
    self.new(params, user_id).tap do |handler|
      handler.create_purchase
    end
  end

  def initialize(params, user_id)
    @user           = User.find(user_id)
    @payment_method = CreditCard.find_by(
      user_id: user_id, 
      last_four_cc: params[:last_four_cc]
    )
    @product_params = params[:products]
    @products       = assign_products
  end

  def assign_products
    products = Product.where(id: product_params.pluck(:id))
    products if products.any?
  end
end  
One last thing we need to think about validating though. We need to know more than just whether or not the products exist in the database, but whether or not we have enough of each product in-stock to accommodate the order. For this, we'll build a custom validator. #### Building a Custom Validator Our custom validator will be called `ProductQuantityValidator` and we'll utilize it in our service class via the following line:
#app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  ...
  validates_with ProductQuantityValidator
We'll define our custom validator in `app/validators/`
# app/validators  
class ProductQuantityValidator < ActiveModel::Validator  
  def validate(record)
    record.product_params.each do |product_data|
      if product_data[:quantity] > products.where(id: product_data[:id])[:quantity]
        record.errors.add :base, "Not enough of product #{product.id} in stock."
     end
    end
  end

Here's how it works:

When we we invoke our handler's validations (coming soon, I promise), the validates_with method is called. This in turn initializes the custom validator, and calls validate on it, with an argument of the handler instance that invoked validates_with in the first place.

Our custom validator looks up the quantity of each selected product for the given record (our handler instance) and adds an error to the record (our handler) if we don't have enough of that product in stock.

Now that we've built our validations, let's write the code to invoke them.

Invoking Validations with the Help of Active Record Callbacks

In our normal Rails models that inherit from ActiveRecord::Base and map to database tables, Active Record calls our validations when the record is saved via the .create, #save or #update methods.

Our service class, as you may recall, does not map to a database table and does not implement a #save method.

It is possible for us to manually fire the validations by calling the #valid? method that ActiveModel::Validations exposes. We could do so in the #initialize method.

# app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  validates :user, presence: true
  validates :credit_card, presence: true
  validates :products, presence: true
  validates_with ProductQuantityValidator
  ...

  def initialize(params, user_id)
    @user           = User.find(user_id)
    @payment_method = CreditCard.find_by(
      user_id: user_id, 
      last_four_cc: params[:last_four_cc]
    )
    @product_params = params[:products]
    @products       = assign_products
    valid?
  end
Let's think about this for a moment though. What is the job of the `#initialize` method? Ruby's `#initialize` method is automatically invoked by calling `Klass.new`, and it's job is to build the instance of our class. It feels just slightly outside the wheelhouse of `#initialize` to not only _build_ the instance, but _also validate it_. I consider this to be a violation of the Single Responsibility Principle. Instead, we want our validations to run automatically for us _after the `#initialize` method executes_. If only there was a way for us to fire certain methods automatically at a certain point in an object's lifecycle... Just kidding. Of course there is! Active Record's callbacks will allow us to do just that. #### Defining Custom Callbacks First, we'll include the `ActiveModel::Callbacks` module in our service.
# app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  include ActiveModel::Callbacks
  ...
Next up, we'll tell our class to enable callbacks for our `#initialize` method.
# app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  include ActiveModel::Callbacks

  define_model_callbacks :initialize, only: [:after]
  ...
The `#define_model_callbacks` method define a list of methods that Active Record will attach callbacks to. Now, we can define our callbacks like this:
# app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  include ActiveModel::Callbacks

  define_model_callbacks :initialize, only: [:after]
  after_initialize :valid?
  ...
Here, we tell our class to fire the `#valid?` (an `Active::Model::Validations` method) after our `#initialize` method. Lastly, in order for our custom callbacks to actually get invoked, we need call the `#run_callbacks` method (courtesy of `ActiveModel::Callbacks`), and wrap the content of our method in a block.
# app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  include ActiveModel::Callbacks

  define_model_callbacks :initialize, only: [:after]
  after_initialize :valid?
  ...
  def initialize(params, user_id)
    run_callbacks do 
      @user           = User.find(user_id)
      @payment_method = CreditCard.find_by(
        user_id: user_id, 
        last_four_cc: params[:last_four_cc]
      )
      @product_params = params[:products]
      @products       = assign_products
    end
  end
And that's it! #### Generating the Purchase Now that we're properly validating our service object, we're ready to actually enact the purchase. Generating a fake purchase for our fictitious online-store that sells (very real) time traveling devices is (unfortunately) *not* the focus on this post. So, we'll just assume we have an *additional* service class, `PurchaseGenerator`, that we'll call on in the `#create_purchase` method and we won't worry about its implementation.
#app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  include ActiveModel::Callbacks

  define_model_callbacks :initialize, only: [:after]
  after_initialize :valid?
  ...
  def create_purchase
    PurchaseGenerator.generate(self)
  end
That was easy! We're so good at programming. Okay, now that we can create purchases, we need to utilize our custom callbacks one more time in order to take care of the post-processing––generating an invoice and sending an email. #### Defining Custom `after` Callbacks Why should this occur in post-processing anyway? Well, we could put that logic in the (not coded here) `PurchaseGenerator` class, or even in some helper methods on the `Purchase` model itself. That creates a level of dependency that we don't find acceptable. Coupling purchase generation with invoice creation and email sending means that _every time you ever create a purchase_ you are generating an invoice and email _every time_. What if you are creating a purchase for a test or administrative user? What if you are manually creating a purchase to give a freebie to a valued customer (The Doctor _is_ constantly buying Tardis replacement parts)? It is certainly possible for a situation to arise in which you do _not_ want to enact both functionalities. Keeping our code nice and modular allows it to be flexible and reusable, not to mention **just gorgeous**. Now that we're convinced what a great idea this is, let's build a custom callback to fire _after_ the `#create_purchase` method to handle invoicing and email confirmations.
#app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  include ActiveModel::Callbacks

  define_model_callbacks :initialize, only: [:after]
  define_model_callbacks :create_purchase, only: [:after]

  after_initialize :valid?
  after_create_purchase :create_invoice
  after_create_purchase :notify_user
  ...
  def create_purchase
    @purchase = PurchaseGenerator.generate(self)
  end

  def create_invoice
    Invoice.create(@purchase)
  end

  def notify_user
    UserPurchaseNotifier.send(@purchase)
  end
#### Serializing Our Service Object We're almost done super-charging our service object. Last but not least, we want to make it possible to leverage `ActiveModel::Serializer` to serialize our object so that we can respond to the `/api/purchases` request with a nice, tidy JSON package. The only thing we need to add to our model is the inclusion of the `ActiveModel::Serialization` module:
# app/services  
class PurchaseHandler  
  include ActiveModel::Validations
  include ActiveModel::Callbacks
  include ActiveModel::Serialization
Now we can define a custom serializer, `PurchaseHandlerSerializer`, and use it like this:
class PurchasesController < ApplicationController  
  def create
    handler = PurchaseHandler.execute(purchase_params, user)
    render json: handler, serializer: PurchaseHandlerSerializer
  end
end  

Our custom serializer will be simple, it will just pluck out some of the attributes we want to serialize:

# app/serializers  
class PurchaseHandlerSerializer < ActiveModel::Serializer  
  attributes :purchase, :products
end  

And that's it!

Conclusion

his has been a brief introduction to some of the more commonly useful modules but I encourage you to delve deep into the Active Record tool box.

Active Model is a powerful and flexible tool. It's functionality extends far beyond the simple "inherit your models from ActiveModel::Base that we're used to seeing on our Rails apps.

MVC is a guideline. It's not written in stone. The main idea is that we don't want to muddy our models, views or controllers with business logic. Instead, we want to create additional objects to handle additional responsibilities. A PORO service is a great place to start, but it isn't always enough. To super-charge our service objects, we can turn to the robust functionality of Active Record.

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus