This is Part II in a series of posts that will address the problems and solutions that I have encountered while building an eCommerce application. Part I
If you’re following our series, we’ve built a Payment
class that has methods to set and get amount
which is a Money
type. Now we’ve reached the point of needing to persist data.
To review, our current implementation of Payment
is:
# app/models/payment.rb
class Payment
attr_reader :amount_cents, :amount_currency
def initialize
@amount_cents = 0
@amount_currency = 'USD'
end
def amount=(amount_value)
raise ArgumentError unless amount_value.respond_to? :to_money
money = amount_value.to_money
@amount_cents = money.cents
@amount_currency = money.currency_as_string
money
end
def amount
Money.new amount_cents, amount_currency
end
end
A Migration
The first thing we’ll need is a quick migration to actually be able to persist our payments.
class CreatePayments < ActiveRecord::Migration
def up
create_table :payments do |t|
t.integer :amount_cents, null: false, default: 0
t.string :amount_currency, null: false
t.timestamps
end
end
def down
drop_table :payments
end
end
Composition with composed_of
Ok, now we just need to inherit from ActiveRecord::Base
and get to saving and retriving this data from our database. We’re also going to make use of composed_of
to eliminate our setters and getters. the full documentation is also available at api.rubyonrails.org.
In essense the method is passed 2 parameters:
- The name of the attribute, as a symbol
- An options hash
The attribute param should be obvious in our case (:amount
), and we’ll look at the options in a little more detail.
The first option we’ll look at is the :class_name
, which again should fairly obviously be “Money” since we always want our amount
to be an instance of Money
.
# app/models/payment.rb
composed_of :amount, class_name: 'Money'
Mapping the Attributes
Next, we’ll look at the :mapping
option. This one is a little less apparent from the start, but makes a bit of sense as we examine it more closely.
The :mapping
key takes an array of arrays. Each array is a one-to-one mapping with the first item being the attribute of the model class, and the second item being the attribute of the target class, in our case: Money
.
The 2 attributes we need to set on our Payment
class are amount_cents
and amount_currency
. These match-up with the cents
and currency_as_string
attributes on the Money
class, respectively.
Attribute in Payment | Attribute in Money |
---|---|
amount_cents | cents |
amount_currency | currency_as_string |
So, about halfway through, our method call should look like this:
# app/models/payment.rb
composed_of :amount,
class_name: 'Money',
mapping: [ %w(amount_cents cents), %w(amount_currency currency_as_string) ]
Getter = Constructor
The next option that we need to set is, :constructor
. This key takes either a method name or a Proc
that is called when getting our amount
. The method or Proc
is passed arguments for each of the attributes set in our :mapping
arrays (amount_cents
, and amount_currency
).
Notice that the constructor is essentially the same implementation as the original amount
method definition.
# app/models/payment.rb
composed_of :amount,
class_name: 'Money',
mapping: [ %w(amount_cents cents), %w(amount_currency currency_as_string) ],
constructor: Proc.new { |cents, currency| Money.new(cents, currency) }
Setter = Converter
The final option that we need to set is, :converter
. This key takes either the name of a method on the class that was set in our earlier :class_name
option, or a Proc
that is called when setting the amount
attribute. This Proc
will be called with one argument (the new value to assign to amount
), and only if the argument passed is not an instance of :class_name
(Money
).
Ok, we’re finally ready to finish off our method call and have a fully monetized amount
attribute for Payment
. Again, notice that this is the same implementation as our original amount=
method.
# app/models/payment.rb
composed_of :amount,
class_name: 'Money',
mapping: [ %w(amount_cents cents), %w(amount_currency currency_as_string) ],
constructor: Proc.new { |cents, currency| Money.new(cents, currency) },
converter: Proc.new { |value| value.respond_to?(:to_money) ? value.to_money : raise(
ArgumentError, "Can't convert #{value.class} to `Money`"
) }
Success! We have a Payment
class that knows how to represent an amount
as actual money, but can save to any database that only knows about integers and strings.
All of the tests from our earlier suite should still be green, and we’ve pushed all of the converting to ActiveRecord
.
Hopefully, you’ve learned a little about the not commonly discussed composed_of
method, and are already envisioning ways to compose your models of multiple different types of classes without worrying about serialzing data into the database.
While this is can come in very handily, it can also be cumbersome to use in multiple models. For example, we would most definitely be violating DRY if we had classes representing Payment
, Product
, LineItem
, Order
that all had essentially the same composed_of
method call. This doesn’t even mention the possibility of having multiple attributes on a model that need to monetized.
So, next time we’ll look into creating our own monetize
class method to take care of all of this for us.