DEV Community

Sophie DeBenedetto
Sophie DeBenedetto

Posted on • Edited on

Smarter Rails Services with Active Model 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.

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
Enter fullscreen mode Exit fullscreen mode

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 possible 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 Model!

Active Model 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 Model 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 and super charge our service!

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 Model modules.

Our service's API is super-simple. It looks like this:

PurchaseHandler.execute(purchase_params, user_id)
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

So, our PurchaseHandler class will expose the following method:

# app/services
class PurchaseHandler
  def self.execute(params, user_id)
    # do the things
  end
end
Enter fullscreen mode Exit fullscreen mode

Active Model 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(&:create_purchase)
  end
end
Enter fullscreen mode Exit fullscreen mode

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 Model 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 Model 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 Model'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(&:create_purchase)
  end

  def initialize(params, user_id)
    @user = User.find_by(user_id)
  end
end
Enter fullscreen mode Exit fullscreen mode

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,:payment_method, :product_params, 
    :products, :purchase

  def self.execute(params, user_id)
    self.new(params, user_id).tap(&:create_purchase)
  end

  def initialize(params, user_id)
    @user = User.find_by(user_id)
    @payment_method = CreditCard.find_by(
      user_id: user_id, 
      last_four_cc: params[:last_four_cc]
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

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(&:create_purchase)
  end

  def initialize(params, user_id)
    @user           = User.find_by(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
    Product.where(id: product_params.pluck(:id))
  end
end
Enter fullscreen mode Exit fullscreen mode

One last thing we need to think about validating. 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
Enter fullscreen mode Exit fullscreen mode

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.find_by(id: product_data[:id])[:quantity]
        record.errors.add :base, "Not enough of product #{product_data[:id]} in stock."
     end
    end
  end
Enter fullscreen mode Exit fullscreen mode

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_by(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
Enter fullscreen mode Exit fullscreen mode

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 Model'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
  ...
Enter fullscreen mode Exit fullscreen mode

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]
  ...
Enter fullscreen mode Exit fullscreen mode

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?
  ...
Enter fullscreen mode Exit fullscreen mode

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_by(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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. We'll define two private helper methods to do that. We'll also use the run_callbacks method to wrap the contents of #create_purchase so that our callbacks will fire.

#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
    run_callbacks do 
      @purchase = PurchaseGenerator.generate(self)
    end
  end

  private

  def create_invoice
    Invoice.create(@purchase)
  end

  def notify_user
    UserPurchaseNotifier.send(@purchase)
  end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And that's it!

Our final PurchaseHandler service looks something like this:

class PurchaseHandler
  include ActiveModel::Validations
  include ActiveModel::Callbacks
  include ActiveModel::Serialization

  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

  attr_reader :payment_method, :purchase, :products, :user, :product_params

  def self.execute(params, user_id)
    self.new(params, user_id).tap(&:create_purchase)
  end

  def initialize(params, user_id)
    run_callbacks do 
      @user           = User.find_by(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

  def create_purchase
    @purchase = PurchaseGenerator.generate(self)
  end

  private

  def create_invoice
    Invoice.create(purchase)
  end

  def notify_user
    UserPurchaseNotifier.send(purchase)
  end
Enter fullscreen mode Exit fullscreen mode

Conclusion

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

Active Model is a powerful and flexible tool. It's functionality extends far beyond the simple "inherit your models from ActiveRecord::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 Model.

Top comments (26)

Collapse
 
skoddowl profile image
skoddowl • Edited

User.find(user_id) will raise an exception if no user found. Maybe it's better to use User.find_by(id: user_id), so if no user found it will set user variable to nil and user presence validation will handle this error?
Also what about to set visibility level of create_purchase, create_invoice and notify_user methods to private since we don't need to access these methods outside of the class?
Great post btw!

Collapse
 
sophiedebenedetto profile image
Sophie DeBenedetto

Hi,

Yes you are totally right about the switch to find_by. I also agree with making those methods private. Thanks for the feedback!

Collapse
 
felipemalinoski profile image
Felipe Matos Malinoski

Awesome post! Can you please add the complete final class to the post??

The only thing I didn't understand was this:

attr_reader :user, :credit_card, :product_params, :products, :purchase
Enter fullscreen mode Exit fullscreen mode

where is the definition of :credit_cardand :purchase??

Thanks again for sharing this!

Collapse
 
sophiedebenedetto profile image
Sophie DeBenedetto

Glad you found it helpful!

I included the final version of the PurchaseHandler service class towards the end of the post. I removed the #credit_card attr_reader as it wasn't being used. The #purchase attribute is set in the #create_purchase method.

Also keep in mind that the code for actually creating a purchase is not described here and isn't totally relevant--just an example to illustrate how we can use some of these super helpful Active Model modules :)

Collapse
 
paulmakepeace profile image
Paul Makepeace

I'm curious why run_callbacks isn't wrapping the contents of #create_purchase as well?

In the ProductQualityValidator there's product.id; did you mean product_data[:id]? The where should be find_by to get a single object back.

Great article! One of the more convincing takes on Ruby service objects for Rails. The callbacks do feel pretty clunky though.

Collapse
 
sophiedebenedetto profile image
Sophie DeBenedetto

Hi there,

Ah yes run_callbacks should absolutely be used in the #create_purchase method! Thanks for bringing that up. The post has been update to reflect that, along with your suggestions for the ProductQualityValidator. Thanks!

Collapse
 
ipmsteven profile image
Yunlei Liu

Hi Sophie, I am curious how you do the error handling in the service ?

Collapse
 
sophiedebenedetto profile image
Sophie DeBenedetto

Hi,

So the use of ActiveModel::Validations gives our instance of PurchaseHandler access to the #errors method. If you look at the ProductQualityValidator, you can see that that it is operating on record. This record attribute of our validator refers to the PurchaseHandler instance that we are validating. We add errors to our instance via record.errors.add. We can call purchase_handler.errors to see any errors that were added to the instance by the validator. You can use these errors however you want--render them in a view for example or use them to populate a JSON api response.

The validator is invoked by the after_initialize :valid? callback that we defined in our PurchaseHandler class.

Collapse
 
ipmsteven profile image
Yunlei Liu • Edited

Thanks for the reply.
I think what you mentioned is the validation error, or validate the input for service.

How about an exception is raised when service object is doing the work?
Like item is out of stock when try to purchase, no item found when db fetch, etc.

Some of the exceptions we can avoid by pre-validate the input parameter, but some can only happen when we execute the code

Thanks

Thread Thread
 
sophiedebenedetto profile image
Sophie DeBenedetto

Ah okay I see what you're saying. I think you can choose how to raise and handle exceptions as you would in any Rails model or service without violating the basic design outlined here. You have a few options available to you--use additional custom validators to add errors to the PurchaseHandler instance, or raise custom errors. I wrote another post on a pattern of custom error handling in similar service objects within a Rails API on my personal blog if you want to check it out: thegreatcodeadventure.com/rails-ap...

Thread Thread
 
ipmsteven profile image
Yunlei Liu

Thanks!

Collapse
 
davidalejandroaguilar profile image
David Alejandro

Great post!

By the way, the final code mysteriously removed the presence validators; and the first part of the post still has presence validators for credit_card, I think that should be payment_method instead.

Collapse
 
alekseyl profile image
Aleksey

Since all purchasing info stored in DB, this example actually will nicely deconstruct to MVC. Where model is a Purchase object, and controller is a PurchasesController.

So at the end you got yourself the good old MVC made in a good old Rails way.

Occam's razor is to the rescue, when you want to add some new essense try the old ones thoroughly.

It may work as an example of ActiveModel use, but it's a purely theoretic example.

Collapse
 
pauldruziak profile image
Paul Druziak

Great post. Just one thing that I miss, how callbacks can help to skip generating an invoice or email? Thanks.

Collapse
 
sophiedebenedetto profile image
Sophie DeBenedetto

Hi there,

What do you mean "skip generating an invoice or email"?

Collapse
 
pauldruziak profile image
Paul Druziak

Oh, my fault. I read post one more time and now understood, thanks.

Collapse
 
johnmeehan profile image
John Meehan

Excellent article!

Collapse
 
joshed profile image
Josh Dzielak 🔆

A+ 👏

Collapse
 
jiprochazka profile image
Jiří Procházka

Wow! Clappin' my hands!