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.
Contents
- The data-model/business-logic argument
- What is ActiveModel::Attributes
- A real-world example
- Breaking it down
- References
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
hood of 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 attribute
API:
So, if you had a Post
table with columns of user_id
, body
, 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.
The model
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
The Controller
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]
In our 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.
References
-
More or less POROs that started off with good intentions but quickly became a mess of copy-pasted business logic ↩