Using Rails ActiveModel::Attributes, and practical ActiveModel examples
One of my clients has a project that makes great use of what most would agree is a service object pattern1, primarily because there’s an attribute that’s not a database attribute that needs to be sent to the business logic.
There are many reasons why you might have an attribute that’s closely-coupled to a data model, but is not otherwise stored. Some of these reasons include:
- Used to calculate the value of a stored attribute
- Used to trigger a callback
- Used in an external API service
Instead of spreading your business logic too thin, you can put this in your model. This post will show you how.
- The data-model/business-logic argument
- What is ActiveModel::Attributes
- A real-world example
- Breaking it down
The data-model/business-logic argument
Models in Ruby on Rails get a bad rap. They’re often cluttered with business logic, view methods, API calls and so much more. Unfortunately, to all kinds of developers, it’s not entirely clear where to put business logic due to lack of proper guidance from the official documentation or community.
This isn’t defending fat models or service objects. There are certainly reasons when a service object pattern makes sense. However, in for the purposes of this feature, I see it as unnecessary.
My personal opinion is that if there is an attribute that is used to set other attributes, it can be argued that it fits into the model.
I’ll be honest: I don’t absolutely hate ActiveRecord callbacks. Their reputation is awful because we often try to do too much with them without looking at the tools available to us.
What is ActiveModel::Attributes
ActiveModel::Attributes is used under the
ActiveRecord to cast database column types to Ruby-friendly
object types. Example: If you’re storing decimals in PostgreSQL, you need to cast them to BigDecimal.
ActiveModel is what handles this under the hood.
ActiveRecord is smart enough to know what columns the model’s table has, so it creates getters and setters on behalf
of the database so you can use familiar Ruby idioms for your database. You can do this yourself by using the
So, if you had a
Post table with columns of
ActiveRecord would automatically do something like this:
class Post < ApplicationRecord attribute :user_id, :integer attribute :body, :string end
Which allows you to:
post = Post.new post.user_id = "1" # assuming you're receiving params from a controller, because all params are strings post.body = "hello" post.user_id.class # Integer post.body.class # string
A real-world example
All good things need a real-world example. We’ll look at how I handle credit cards for one of my projects. This project uses Stripe for payment processing and subscription management via Stripe Billing.
Below is a truncated version of the
CreditCard model used by this SaaS (and an upcoming open-source SaaS framework!).
We’ll go through the relevant goodies that’s provided by
ActiveModel to create Stripe-backed credit cards.
class CreditCard < ApplicationRecord belongs_to :user, inverse_of: :credit_cards attribute :token, :string validates :stripe_card_id, uniqueness: true validates :last4, :exp_mo, :exp_year, :stripe_card_id, presence: true before_validation :create_stripe_card!, on: [:create] private def stripe_customer @stripe_customer ||= user.as_stripe_customer end def create_stripe_card! stripe_request = stripe_customer.sources.create(source: token) self.last4 = stripe_request.last4 self.kind = stripe_request.brand self.exp_mo = stripe_request.exp_month self.exp_year = stripe_request.exp_year self.stripe_card_id = stripe_request.id rescue Stripe::StripeError => e errors.add(:token, e.message) throw(:abort) rescue StandardError => e errors.add(:base, e.message) throw(:abort) end end
In the controller that handles credit cards, to create a new card, we simply (briefly),
def create @card = current_user.credit_cards.new(token: card_params[:token]) if @card.save flash[notice] = "Successfully added card." else flash[:error] = @card.errors.full_messages.to_sentence end redirect_back(fallback_location: user_credit_cards_path) end
Breaking it down
token is has a getter and setter defined on our
CreditCard model via
ActiveModel::Attributes. It’s not backed by
the database, so we really don’t care about it on an instance-by-instance basis.
attribute :token, :string
Then, we use it do set attributes that we do care about before we validate the object.
before_validation :create_stripe_card!, on: [:create]
create_stripe_card! method, we use Stripe’s Ruby SDK to create our card.
def create_stripe_card! stripe_request = stripe_customer.sources.create(source: token) self.last4 = stripe_request.last4 self.kind = stripe_request.brand self.exp_mo = stripe_request.exp_month self.exp_year = stripe_request.exp_year self.stripe_card_id = stripe_request.id rescue Stripe::StripeError => e errors.add(:token, e.message) throw(:abort) rescue StandardError => e errors.add(:base, e.message) throw(:abort) end
Of note, we can access the
token we defined earlier just how we would any other database-backed attribute.
Inside this method, we rescue from errors and apply them as necessary to the object, applying errors to our object that act like database-backed columns too.
Assuming everything goes to plan, we’ll persist this object to our database and we’ll never see our beloved token again — because we don’t have any use for it. I personally find this approach to be a lot cleaner than using a separate class to create this object and provide feedback to users.
More or less POROs that started off with good intentions but quickly became a mess of copy-pasted business logic ↩