Presentation made at the second Miłośnicy Ruby Warsaw Meetup hosted in Visuality office on August 25th, 2022.
Paweł Strzałkowski introduces listeners to Domain-Driven Design and shows how it can be used to model business domain in Ruby on Rails framework on the tactical level.
24. Tactical DDD
• How to express a model of a domain
• Embrace object oriented programming
25. Tactical DDD Elements
• Entity
• Value Object
• Aggregate
• Repository
• Factory
• Service
• Domain Event
26. Tactical DDD Elements: Entity
An object de
fi
ned primarily by its identity is called an ENTITY.
- Eric Evans
• Identity
• Behavior
27. Tactical DDD Elements: Entity - Identity strategies
• user driven
• application generated
• persistence store generated
• outside
28. Tactical DDD Elements: Entity - Indispensable behavior
• domain objects have behavior essential to the context
• setter or getter for every primitive do not express behaviour
customer.change_phone_number("+48 123 555 123")
invoice.void(reason: "Client changed his mind")
account.activate
29. Tactical DDD Elements: Value Object
An object that represents a descriptive aspect of the domain with no
conceptual identity is called a VALUE OBJECT
- Eric Evans
• Represents value
• Immutable
• Comparable
• No identity
30. Tactical DDD Elements: Aggregate
An AGGREGATE is a cluster of associated objects that we treat as a unit
for the purpose of data changes.
- Eric Evans
• Aggregate Root
• Boundary
• Invariants
Aggregate Root
* invariants
Value Object
Entity
Value Object
33. Tactical DDD Elements: Value Object - IRL Example
class TrackEvent
def initialize(user_id:, event:)
self.user_id = user_id
self.event = event
end
def call
return false unless Configuration.enabled?
Client.track(
user_id: user_id,
event: event,
properties: properties
)
end
private
# ...
end
34. Tactical DDD Elements: Value Object - IRL Example
class TrackEvent
def initialize(user_id:, noun: nil, verb: nil)
self.user_id = user_id
self.event = "#{noun} #{verb}"
end
def call
return false unless Configuration.enabled?
Client.track(
user_id: user_id,
event: event,
properties: properties
)
end
private
# ...
end
35. Tactical DDD Elements: Value Object - IRL Example
class TrackEvent
def initialize(user_id:, noun: nil, verb: nil, event: nil)
self.user_id = user_id
self.event = event.presence || "#{noun} #{verb}"
end
def call
return false unless Configuration.enabled?
Client.track(
user_id: user_id,
event: event,
properties: properties
)
end
private
# ...
end
36. Tactical DDD Elements: Value Object - IRL Example
class Event
attr_reader :name
def initialize(name:)
@name = name.to_s
end
def self.combination_of(noun:, verb:)
new(name: "#{noun} #{verb}")
end
def ==(other)
name == other.name
end
end
Event.new(name: 'Job Created')
Event.combination_of(noun: 'Job', verb: 'Started')
37. Tactical DDD Elements: Value Object - IRL Example
class TrackEvent
def initialize(user_id:, event:)
self.user_id = user_id
self.event = event.name
end
def call
return false unless Configuration.enabled?
Client.track(
user_id: user_id,
event: event,
properties: properties
)
end
private
# ...
end
38. Tactical DDD Elements: Value Object - RoR Example
class Place < ApplicationRecord
# name, latitude, longitude
end
39. Tactical DDD Elements: Value Object - RoR Example
class Place < ApplicationRecord
# name, latitude, longitude
composed_of :coordinates
end
40. Tactical DDD Elements: Value Object - RoR Example
class Place < ApplicationRecord
# name, latitude, longitude
composed_of :coordinates,
class_name: 'Coordinates'
mapping: [%w[latitude latitude], %w[longitude longitude]]
end
class Coordinates
attr_reader :latitude, :longitude
def initialize(latitude, longitude)
@latitude = latitude
@longitude = longitude
end
def ==(other)
latitude == other.latitude && longitude == other.longitude
end
end
43. Domain Modeling in Ruby on Rails - Excercise
• Customers may be contacted through an email or a phone
44. Domain Modeling in Ruby on Rails - Excercise
• Customers may be contacted through an email or a phone
• When a customer contacts us, we use their phone numbers to
fi
nd
the records in our books
45. Domain Modeling in Ruby on Rails - Excercise
• Customers may be contacted through an email or a phone
• When a customer contacts us, we use their phone numbers to
fi
nd
the records in our books
• Each customer has to have a name and a physical address
46. Domain Modeling in Ruby on Rails - Excercise
• Customers may be contacted through an email or a phone
• When a customer contacts us, we use their phone numbers to
fi
nd
the records in our books
• Each customer has to have a name and a physical address
• Birthdays are very important, we use them for marketing
47. Domain Modeling in Ruby on Rails - Excercise
• Customers may be contacted through an email or a phone
• When a customer contacts us, we use their phone numbers to
fi
nd
the records in our books
• Each customer has to have a name and a physical address
• Birthdays are very important, we use them for marketing
• We have a loyalty program based on the amount of time customer is
with us, we need to know when a customer was added
48. Domain Modeling in Ruby on Rails - Excercise
• Customers may be contacted through an email or a phone
• When a customer contacts us, we use their phone numbers to
fi
nd
the records in our books
• Each customer has to have a name and a physical address
• Birthdays are very important, we use them for marketing
• 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; they can rate others with a positive,
neutral or a negative mark, there can be one rate for one customer
52. Excercise - With 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 Rate < ApplicationRecord
belongs_to :customer
belongs_to :rated_customer,
class_name: 'Customer'
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
53. Excercise - Highlighting identity
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 Rate < ApplicationRecord
belongs_to :customer
belongs_to :rated_customer,
class_name: 'Customer'
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], %[email email]
]
validates_with PersonalInformationValidator
validates_with PhoneSyntaxValidator
validates :phone, uniqueness: true
has_many :rates
end
54. Excercise - Adding behavior
class Customer < ApplicationRecord
AlreadyRated = Class.new(StandardError)
CannotRateSelf = Class.new(StandardError)
# ...
has_many :rates
def give_negative_rate(other) = give_rate(other, -1)
def give_neutral_rate(other) = give_rate(other, 0)
def give_positive_rate(other) = give_rate(other, 1)
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
55. Excercise - Ugly service
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
56. Excercise - Intention revealing service
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
57. Excercise - aggregate implementation
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
58. Excercise - aggregate implementation
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
Add optimistic locking
add_column :customers, :lock_version, :integer
59. We did it!
• Modelled the domain according to knowledge of a domain expert
• Created expressive and intention revealing code
• Guaranteed safety in concurrent operations
How?
• Avoided ORM-based design
• Listened to the domain expert
• Spent minimal time thinking
60. The job is not done
• Customer model is responsible for handling:
• address used for package delivery
• birthdays used for marketing actions
• email used for transactional email
61. The job is not done
• Customer model is responsible for handling:
• address used for package delivery
• birthdays used for marketing actions
• email used for transactional email
But that's a story for another time...
62. References
• Introduction to DDD in Ruby on Rails
• https://www.visuality.pl/posts/introduction-to-ddd-in-ruby-on-rails
• GitHub Repository with used examples
• https://github.com/pstrzalk/ddd-in-ruby-on-rails