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.
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 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)
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 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
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
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
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
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
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
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
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
...
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_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
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. 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
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!
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
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)
User.find(user_id)
will raise an exception if no user found. Maybe it's better to useUser.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!
Hi,
Yes you are totally right about the switch to
find_by
. I also agree with making those methods private. Thanks for the feedback!Awesome post! Can you please add the complete final class to the post??
The only thing I didn't understand was this:
where is the definition of
:credit_card
and:purchase
??Thanks again for sharing this!
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 :)
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 meanproduct_data[:id]
? Thewhere
should befind_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.
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 theProductQualityValidator
. Thanks!Hi Sophie, I am curious how you do the error handling in the service ?
Hi,
So the use of
ActiveModel::Validations
gives our instance ofPurchaseHandler
access to the#errors
method. If you look at theProductQualityValidator
, you can see that that it is operating onrecord
. Thisrecord
attribute of our validator refers to thePurchaseHandler
instance that we are validating. We add errors to our instance viarecord.errors.add
. We can callpurchase_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 ourPurchaseHandler
class.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
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...Thanks!
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.
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.
Great post. Just one thing that I miss, how callbacks can help to skip generating an invoice or email? Thanks.
Hi there,
What do you mean "skip generating an invoice or email"?
Oh, my fault. I read post one more time and now understood, thanks.
Excellent article!
A+ 👏
Wow! Clappin' my hands!