In this article, we explore the concept of Value Objects in Ruby on Rails and how they can be used to encapsulate business logic and improve the maintainability of your code. We start by explaining what a Value Object is and how it differs from a Rails Model, before diving into how to create custom Value Objects in Ruby. We also provide an example of how to use a custom Value Object as an attribute in a Rails Model, along with the necessary configuration and implementation details. Finally, we discuss how to organize Value Objects within a Rails application for maximum clarity and ease of use.
Can a Rails Model be Considered a Value Object?
No, a Rails Model is not the same thing as a Value Object. A Value Object is a small, immutable object that represents a simple value or concept, such as a name, address, or monetary amount. In contrast, a Rails Model is a more complex object that represents a database table and includes functionality for querying and updating the associated records.
Can We Use Value Object Concepts in a Rails Model?
Yes, you can use Value Object concepts in a Rails Model by creating custom Value Objects and using them as attributes in your Model. This allows you to encapsulate business logic in a separate object and keep your Model lean and focused on database-related concerns.
Should We Create Separate Models or Use Value Objects?
It depends on the complexity of the business logic you need to represent. If the business logic involves complex relationships or requires extensive querying and updating of associated records, it may be better to create separate Models. If the business logic is relatively simple and can be represented as a standalone value or concept, it may be better to use a Value Object.
Creating a Value Object
Here is an example of how you might create a Value Object for representing monetary amounts:
class Money
attr_reader :amount, :currency
def initialize(amount, currency = 'USD')
@amount = amount
@currency = currency
end
def add(other)
raise "Mismatched currency" unless currency == other.currency
Money.new(amount + other.amount, currency)
end
# Other methods for performing arithmetic operations, formatting output, etc.
end
In this example, we define a Money
class that represents a monetary amount with an associated currency. The class includes methods for performing arithmetic operations and other functionality related to monetary amounts.
Another example is a Value Object to represents an address:
class Address
attr_reader :street, :number, :complement, :city, :state, :zip_code
def initialize(street:, number:, complement:, city:, state:, zip_code:)
@street = street
@number = number
@complement = complement
@city = city
@state = state
@zip_code = zip_code
end
def eql?(other)
return false unless other.is_a?(Address)
street == other.street &&
city == other.city &&
state == other.state &&
zip_code == other.zip_code
end
alias_method :==, :eql?
def hash
[street, number, complement, city, state, zip_code].hash
end
def to_s
"#{street}, #{number}, #{complement} - #{city}/#{state}, #{zip_code}"
end
end
Using a Value Object in a Rails Model
Here is an example of how you might use the Address
Value Object as an attribute in a Rails Model:
class Order < ApplicationRecord
attribute :shipping_address, AddressType.new
end
using this method you need to make some aditional configurations on config/initializers/types.rb
to register the new custom type like:
ActiveSupport.on_load(:active_record) do
ActiveRecord::Type.register :address_type, AddressType
end
you still have to create a class, that inherits from ActiveRecord::Type::Value
, that describes the convertion of the Address
value object into a really usable type.
class AddressType < ActiveRecord::Type::Value
def type
:jsonb
end
def cast(value)
return if value.blank?
if value.is_a?(Address)
value
elsif value.is_a?(Hash)
Address.new(value.symbolize_keys)
else
raise ArgumentError, "Invalid address value: #{value.inspect}"
end
end
def serialize(value)
return nil if value.blank?
value.to_s
end
def deserialize(value)
value
end
def ==(other)
other.is_a?(AddressType)
end
end
- In the
type
method we are defining the real type that ‘ll be saved on the database - In the
cast
method has the responsability to casts a value from user input (e.g. from a setter). This value may be a string from the form builder, or a ruby object passed to a setter. There is currently no way to differentiate between which source it came from. The return value of this method will be returned from[ActiveRecord::AttributeMethods::Read#read_attribute](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Read.html#method-i-read_attribute)
. - The
serialize
method casts a value from the ruby type to a type that the database knows how to understand. The returned value from this method should be aString
,Numeric
,Date
,Time
,Symbol
,true
,false
, ornil
. - and the
deserialize
method converts a value from database input to the appropriate ruby type. The return value of this method will be returned fromActiveRecord::AttributeMethods::Read#read_attribute
. The default implementation just calls[Value#cast](https://api.rubyonrails.org/classes/ActiveModel/Type/Value.html#method-i-cast)
.
Organizing Value Objects in a Rails Application
To organize your Value Objects in a Rails application, you can create a separate directory for them within the app directory. Here is an example directory structure:
app/
├── controllers/
├── models/
│ ├── order.rb
├── value_objects/
│ └── money.rb
└── address.rb
In this example, we create a value_objects directory within the app directory to store our custom Value Objects. We then create a money.rb
file within the value_objects directory to define our Money Value Object.
We can also use composed_of
to use the value object
You can use the composed_of method in Rails to simplify the use of the Money value object in the Order
model.
Here’s an example of how you could use composed_of:
class Order < ApplicationRecord
composed_of :total_value,
class_name: 'Money',
mapping: %w[total_value amount currency],
converter: ->(value) { Money.new(value.amount, value.currency) },
allow_nil: true
end
In this example, the composed_of
method is used to define a total_value
attribute in the Order
model. The :class_name
option specifies the name of the value object class (in this case, Money). The :mapping
option maps the total_value
attribute to the cents attribute of the Money object. The :converter
option specifies a lambda that converts the total_value
attribute to a Money object. The :allow_nil
option specifies that the total_value
attribute can be set to nil.
With this setup, you can treat the total_value
attribute as a Money object in your code, like this:
order = Order.new(total_value: Money.new(100, 'usd'))
order.total_value # returns a Money object with usd100
This approach allows you to abstract away the details of how the Money
object is stored in the database and provides a simpler interface for working with the total_value
attribute.
Conclusion
In conclusion, using value objects in a Ruby on Rails application can help you encapsulate business logic in a separate object and keep your models focused on database-related concerns. By using value objects to represent simple values or concepts, such as monetary amounts or addresses, you can make your code more readable, testable, and maintainable.
Creating a value object is relatively easy in Ruby, and you can use it as an attribute in your Rails models. You can also create custom types to handle the conversion of your value object to a database column. By organizing your value objects in a separate directory, you can make it easier to maintain and reuse them across your application.
references:
-
ActiveRecord::Aggregations::ClassMethods
. Ruby on Rails API Documentation. Retrieved March 31, 2023, from https://api.rubyonrails.org/classes/ActiveRecord/Aggregations/ClassMethods.html -
ActiveModel::Type::Value
. Ruby on Rails API Documentation. Retrieved March 31, 2023, from https://api.rubyonrails.org/classes/ActiveModel/Type/Value.html#method-i-serialize - Fowler, M. (2003, August 20). ValueObject. Martin Fowler. Retrieved March 31, 2023, from https://martinfowler.com/bliki/ValueObject.html
Top comments (2)
There really isn't anything about the
app/models
folder that forces you to store there only objects that inherit from ActiveRecord. That folder is actualy meant as a home for all types of models, regardless if they are backed up by a database table or not.Obviously, if you feel like it makes more sense for you or your team to put value objects in a separate folder, like you did in that example.
Really great article.
It would be nice if you could add a section on
serialize
with a custom coder, which seems to be a valid approach as well (see gorails.com/episodes/custom-active...).It looks to me that
serialize
with a custom coder can achieve pretty much the same as theattribute
with a customActiveRecord::Type
.The
composed_of
, however, has the added benefit of being able to create a value object that (as the name implies) is composed of multiple columns of the model, instead of a single one, so it's more versatile.