It's Domain-Driven Design exercise time. Let's take the knowledge from articles about Value Objects and Entities and design an entity, as a piece of a domain model.
Disclaimer: This article operates only on the elements discussed so far - Entities and Value Objects. A more experienced reader may notice a lack of aggregates, repositories, factories or domain services. Those should come in later articles. We are taking one step at a time.
Job to do
Our client needs an application to help dealing with customers. We were able to gather the initial requirements for it:
- Customers may be contacted through an email or a phone
- When a customer contacts us, we use their phone numbers to find the records in our books
- Each customer has to have a name and a physical address associated
- Customers' birthdays are very important, as we use them for marketing reasons
- We have a loyalty program based on the amount of time customer is with us, we need to know when a customer was added
- Customers get into interactions between each other; they can rate others with a positive, neutral or a negative mark
ORM-driven solution
Ruby on Rails is all about ActiveRecord, so let's use it to create a straightforward solution:
The models contain all the needed information and can be easily represented with two tables in a relational database. This way, we've created nice bags for data. Customer class defines a setter and a getter for each attribute. We can use service objects to define possible user actions with clean procedures. We can create as many rates as possible by persisting instances of the Rate class.
Check out a sample Ruby on Rails implementation below. Please note that the used validators are not discussed, as their logic is not relevant to the article.
class Customer < ApplicationRecord
# The following attributes have implicit getters and setters
# attr_accessor :phone, :email, :name, :city, :street, :birthday, :created_at
validates :city, :street, :name, :birthday, presence: true
validates_with PhoneSyntaxValidator
validates :phone, uniqueness: true
validates_with EmailSyntaxValidator
validates :email, uniqueness: true
validate :birthday_in_the_past
has_many :rates
private
def birthday_in_the_past
errors.add(:birthday, "is in the future") if birthday&.future?
end
end
class Rate < ApplicationRecord
belongs_to :customer
belongs_to :rated_customer, class_name: 'Customer'
end
But let’s be honest. No design has been applied. No intention or behavior is revealed when looking at the classes. There are numerous, publicly available primitives, which blur entity's original purpose. It doesn't answer the most important questions:
- What defines customer's identity?
- What is customer’s behavior?
Expressing attributes with Value Objects
In one of the previous articles, I have outlined the idea of value objects. They are used to encapsulate attributes of an entity. We can apply this knowledge here to create a more expressive domain model:
class Address
attr_reader :city, :street
def initialize(city, street)
@city = city
@street = street
end
end
class PersonalInformation
attr_reader :name, :birthday
def initialize(name, birthday)
@name = name
@birthday = birthday
end
end
class Customer < ApplicationRecord
composed_of :address, class_name: 'Address', mapping: [%w[city city], %w[street street]]
validates_with AddressValidator
composed_of :personal_information, class_name: 'PersonalInformation', mapping: [%w[name name], %w[birthday birthday]]
validates_with PersonalInformationValidator
validates_with PhoneSyntaxValidator
validates :phone, uniqueness: true
validates_with EmailSyntaxValidator
validates :email, uniqueness: true
has_many :rates
end
class Rate < ApplicationRecord
belongs_to :customer
belongs_to :rated_customer, class_name: 'Customer'
end
It's now clear that a customer has an address and some personal information. We don't have to decipher it from a bag of data. We can assign an address and we can compare addresses between customers. Neat! There is still an unresolved problem with the identity. The client had described that phone numbers are used for matching customers. Moreover, we've realized that the client had never mentioned a constrain on email's uniqueness. It had been added in the first solution, when it wasn't clear what's the identity provider. Let's adjust the design.
Extracting identity
Email address is not a part of customer's identity. It's a piece of information used for contact purposes. But it's not obvious where it should be moved to.
- It is possible to create the third (after
Address
andPersonalInformation
) value object class -ContactInformation
. We could put the email there. But for now we have no ideas for other members of such a creation. - An email address is a kind of contact information so it could be merged with the physical address into a new
ContactInformation
class. However, our client often uses the concept of a physical address so polluting it with email may hurt us in the future. - Finally, email is a part of personal information so it wouldn't be too much of a stretch to put the email inside of the
PersonalInformation
class
Each solution has its pros and cons and it's up to the designer to consider them all. For the sake of simplicity, we'll go with the latter solution. Email goes into the PersonalInformation
class.
Updated implementation:
class Address
attr_reader :city, :street
def initialize(city, street)
@city = city
@street = street
end
end
class PersonalInformation
attr_reader :name, :birthday, :email
def initialize(name, birthday, email)
@name = name
@birthday = birthday
@email = email
end
end
class Customer < ApplicationRecord
composed_of :address, class_name: 'Address', mapping: [%w[city city], %w[street street]]
validates_with AddressValidator
composed_of :personal_information, class_name: 'PersonalInformation', mapping: [%w[name name], %w[birthday birthday], %w[email email]]
validates_with PersonalInformationValidator
validates :phone, uniqueness: true
validates_with PhoneSyntaxValidator
has_many :rates
end
class Rate < ApplicationRecord
belongs_to :customer
belongs_to :rated_customer, class_name: 'Customer'
end
Adding Behavior
In Rails, from the moment we define data structures, all the needed API is in place. We can just use:
customer.rates.build(rated_customer: other_customer, mark: -1)
customer.rates.build(rated_customer: other_customer, mark: 0)
customer.rates.build(rated_customer: other_customer, mark: 1)
Such code can be put into a service object and steer application's logic to achieve the desired result. However, we can do better. We may aim towards an intention revealing interface, which explicitly describes its goal and purpose.
class Customer < ApplicationRecord
AlreadyRated = Class.new(StandardError)
CannotRateSelf = Class.new(StandardError)
# ...
def give_negative_rate(other)
give_rate(other, -1)
end
def give_neutral_rate(other)
give_rate(other, 0)
end
def give_positive_rate(other)
give_rate(other, 1)
end
private
def give_rate(other, mark)
raise CannotRateSelf if other == self
raise AlreadyRated if rates.any? { |rate| rate.rated_customer == other }
rates.build(rated_customer: other, mark: mark)
end
end
With such a descriptive behavior, imagine a service object:
# app/services/customers/give_positive_rate_service.rb
module Customers
class GivePositiveRateService
def initialize(customer_id, rated_customer_id)
@customer_id = customer_id
@rated_customer_id = rated_customer_id
end
def call
customer = Customer.find(@customer_id)
rated_customer = Customer.find(@rated_customer_id)
customer.give_positive_mark(rated_customer)
customer.save
end
end
end
Are there any questions which need to be asked when reading it? Are any comment lines needed? Compare it with a general customer rating service written around an anemic model:
module Customers
class RateService
MarkOutOfBounds = Class.new(StandardError)
AlreadyMarked = Class.new(StandardError)
CannotRateSelf = Class.new(StandardError)
def initialize(customer_id, rated_customer_id, mark)
@customer_id = customer_id
@rated_customer_id = rated_customer_id
@mark = mark
end
def call
raise CannotRateSelf if customer_id == rated_customer_id
customer = Customer.find(@customer_id)
rated_customer_id = Customer.find(@rated_customer_id)
raise MarkOutOfBounds unless [-1, 0, 1].include?(mark)
alread_rated = customer.rates.any? { |rate| rate.rated_customer == rated_customer }
raise AlreadyMarked if alread_rated
customer.rates.build(marked: other, mark: mark)
customer.save
end
end
end
How easy would it be to test the latter service object? It contains so much branching logic, that it would take 10+ test scenarios to cover the basics. Yes, such a service would be much more flexible and reusable. However, such reusability causes more problems than it solves.
Exposing a Person
Using value objects brings readability and clarity to the design. But there are other factors to consider. Stepping aside from the initial job to do, let's think what would happen if customers had access to this catalog and were able to update their personal information. In such a case, each modification would update the entire Customer
record - since PersonalInformation
class is in fact composed of Customer’s attributes.
It may be useful to transform the PersonInformation
class into an entity. PersonInformation
attributes are not restricted by invariants guarded by the Customer
customer class. They can be freely changed as long as they pass syntax validation.
Therefore, transforming it into a Person
entity will be straightforward. The design would look almost identical:
We don't need to maintain a user-defined identity for a person. A meaningful identity is always assigned to a customer. A database-generated ID should be sufficient for a person then.
class Address
attr_reader :city, :street
def initialize(city, street)
@city = city
@street = street
end
end
class Person < ApplicationRecord
validates_with PersonValidator
end
class Customer < ApplicationRecord
composed_of :address, class_name: 'Address', mapping: [%w[city city], %w[street street]]
validates_with AddressValidator
validates :phone, uniqueness: true
validates_with PhoneSyntaxValidator
has_one :person
has_many :rates
end
class Rate < ApplicationRecord
belongs_to :customer
belongs_to :rated_customer, class_name: 'Customer'
end
How come Person
does not belong_to
a Customer
???
Yes. Think about scenarios where it is needed to fetch a customer from a Person
object? My bet - next to never. The most reasonable scenario would be - at person edit view. However, in fact it wouldn't be needed even there. Most probably an authenticated customer updates its own Person
object. The reference to Customer
instance is already present in the session. On the other hand, in the case of an administrator use case, it's easy to imagine browsing customers, not people.
By defining one-way traversal possibility, you simplify the design and make it more robust. You show the intention and shape your domain better. It also helps avoiding issues with eager loading unnecessary associations.
If there is an edge case where a person context actually needs its customer, it's still possible to fetch a person using a customer repository. In case of Ruby on Rails, repository is usually the Customer class itself:
Customer.find_by(person_id: person.id)
I'll use a quote from The Blue Book for further explanation:
"It is important to constrain relationships as much as possible. A bidirectional association means that both objects can be understood only together. When application requirements do not call for traversal in both directions, adding a traversal direction reduces interdependence and simplifies the design. Understanding the domain may reveal a natural directional bias."
— Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
Summary
By no means the designed domain is 100% ready. It still needs some thinking and adjusting to client's needs. But the goal of this article was to outline the style of thinking and the elements which need to be thought through. I urge you to drop the ORM-driven approach in favor of identity + behavior-driven one. Please come back for the next chapters to see how these entities help shaping a rich and meaningful domain model.
Resources
Domain-Driven Design Webinars
Articles in this series
- Introduction to DDD in Ruby on Rails
- Value Object
- Entity
- How to design an entity
- Aggregate
- Repository
- Bounded Context
- Example Application