How to design an entity - DDD in Ruby on Rails

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:

Diagram with ORM-driven 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:

Diagram containing Value Objects

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 and PersonalInformation) 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:

Diagram with Person model exposed

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

Articles in this series