Recently, I had to implement a feature that introduced the concept of currency to an API. Users of the API could create different type of resources, and each of these resources needed to know the type of currency being used. For instance, a user could create a Payment
in USD, or request a Withdrawal
in AUD.
In this short blog post, my goal is to explain how I used ActiveSupport::Concern
to encapsulate and group together all data access and validation of the currency
attribute in a single place.
The Gist
I wanted currency to be an enum
where each value was a currency code. Using enum
allows to write queries by name, such as Payment.first.currency.GBP?
or Payment.where(currency: :AUD)
. If such logic was only needed in a single model, I would have probably done the following:
class Payment < ApplicationRecord
enum currency: { USD: 0, AUD: 1, GBP: 2 }
validates :currency, inclusion: { in: currencies.keys }
end
Nonetheless, the same notion of currency needed to exist in multiple models. To solve this, I chose to use ActiveSupport::Concern
in order to DRY the models and group related concerns of logic together. As described by DHH in this blog post in 2012, ActiveSupport::Concern
"encapsulate[s] both data access and domain logic about a certain slice of responsibility."
The Solution
To implement the desired solution, I created a Currency
module in app/models/concerns/currency.rb
as follows:
module Currency
extend ActiveSupport::Concern
include ActiveModel::Validations
included do
enum currency: { USD: 0, AUD: 1, GBP: 2 }
validates :currency, inclusion: { in: currencies.keys }
end
end
The included
method takes a block, and it is executed at any time a module is included in another module or class. In this case, the currency
module defines an enum
attribute which specifies a named value for each currency. It also validates the currency
attribute is set to one of the values defined in the enum
. To use the concern, simply include it:
class Payment < ApplicationRecord
include Currency
end
class Withdrawal < ApplicationRecord
include Currency
end
And that is it! The Payment
and Withdrawal
models now define how to access and validate the currency
attribute (assuming a migration for the currency
column was created and ran appropriately in each table).
Testing
Testing shared behavior of classes or modules can be greatly simplified by using rspec
's shared examples. In spec/support/shared_examples/currency_spec.rb
I defined a shared example (note that I'm using the shoulda-matchers
gem):
shared_examples_for 'currency' do
let(:model) { described_class }
it { should define_enum_for(:currency).with_values(USD: 0, AUD: 1, GBP: 2) }
describe 'validations' do
it { should allow_value(model.currencies[:USD]).for(:currency) }
it { should allow_value(model.currencies[:AUD]).for(:currency) }
it { should allow_value(model.currencies[:GBP]).for(:currency) }
end
end
To use it, just include the shared example in the models context:
RSpec.describe Payment, type: :model do
it_behaves_like 'currency'
end
RSpec.describe Withdrawal, type: :model do
it_behaves_like 'currency'
end
Done! Models that include the concern can easily test its functionality by using a shared example as described above.
Conclusion
In this short blog post, I have shown a simple solution to encapsulate and group together data access and validation of an attribute. Note I have chosen to use ActiveSupport::Concern
because the same attribute is used by many models. If the currency
attribute was only used by a single model, I would not have defined a concern for it. Finally, I have demonstrated how to use rspec
's shared examples to simplify testing shared behavior of classes or modules.
Have you ever implemented something similar? How did you do it? Let me know in a comment below :).
Top comments (0)