ddd



ddd

0 0


ddd

Value objects, Aggregates, #sf in Rails

On Github paneq / ddd

Value objects and Aggregates in Active Record

robert @pankowecki

@arkency

Why such talk?

DDD by Evans

Agenda

value objects

aggregates

#sf

Value objects

  • attributes define identity/equality
  • immutable

YearMonth

class YearMonth < Struct.new(:year, :month)
  include Comparable

  def initialize(year, month)
    raise ArgumentError unless Fixnum === year
    raise ArgumentError unless Fixnum === month
    raise ArgumentError unless year >= 0
    raise ArgumentError unless month >= 0 && month <= 12

    super
  end

  def next
    if month == 12
      self.class.new(year+1, 1)
    else
      self.class.new(year, month+1)
    end
  end
  alias_method :succ, :next

  def <=>(other)
    (year <=> other.year).nonzero? || month <=> other.month
  end

  def beginning_of
    Time.new(year, month, 1)
  end

  def end_of
    beginning_of.end_of_month
  end

  private :year=, :month=
end

may2014  = YearMonth.new(2014, 5)
may2014  == YearMonth.new(2014, 5) # true
june2014 = may2014.next

YearMonth in AR

class SalesforceConfiguration < ActiveRecord::Migration
  def up
    create_table :salesforces, id: false do |t|
      t.integer :start_year,  null: false
      t.integer :start_month, null: false

      t.integer :end_year,  null: false
      t.integer :end_month, null: false
    end

    execute "INSERT INTO salesforces VALUES(2013, 10, 2014, 7)"
  end

  def down
    drop_table :salesforces
  end
end

class Reporting::Salesforce::Configuration < ActiveRecord::Base
  self.table_name = "salesforces"
  composed_of :start, class_name: YearMonth.name, mapping: [ %w(start_year year), %w(start_month month) ]
  composed_of :end,   class_name: YearMonth.name, mapping: [ %w(end_year year), %w(end_month month) ]

  def each_month
    (self.start..self.end)
  end
end

Configuration.first!.each_month.map do |m|
 "Organization revenue #{m.year}-#{'%02d' % m.month}"
end

SchoolYear

class SchoolYear < Struct.new(:start_year, :end_year)
  def self.from_date(date)
    year = date.year
    if date < Date.new(year, 8, 1)
      new(year-1, year)
    else
      new(year, year+1)
    end
  end

  def self.from_id(id)
    start_year, end_year = *id.split("-")
    start_year = Integer(start_year)
    end_year   = Integer(end_year)
    new(start_year, end_year)
  end

  def self.from_years(start_year_string, end_year_string)
    new( Integer(start_year_string), Integer(end_year_string) )
  end

  def self.current
    from_date(Date.current)
  end

  def initialize(start_year, end_year)
    raise ArgumentError unless Fixnum === start_year
    raise ArgumentError unless Fixnum === end_year
    raise ArgumentError unless start_year >= 0
    raise ArgumentError unless start_year+1 == end_year
    super(start_year, end_year)
  end

  def name
    [start_year, end_year].join("/")
  end

  def id
    [start_year, end_year].join("-")
  end

  def next
    self.class.new(start_year.next, end_year.next)
  end

  def prev
    self.class.new(start_year.prev, end_year.prev)
  end

  def starts_at
    Date.new(start_year, 8, 1)
  end

  def ends_at
    Date.new(end_year, 7, 31)
  end

  private :start_year=, :end_year=
end

school_year  = SchoolYear.current
sy_2013_2014 = SchoolYear.from_id("2014-2015")
sy_2014_2015 = SchoolYear.new(2014, 2015)
sy_2015_2016 = SchoolYear.from_years("2014", "2015")

SchoolYear in AR - serialize, deserialize

module School
  module SchoolYearSerializer
    def school_year=(sy)
      self['school_year_id']= sy.id
    end

    def school_year
      id = self['school_year_id']
      return nil if id.nil?
      SchoolYear.from_id(id)
    end
  end
end

Klass with SchoolYear

create_table "school_classes", :force => true do |t|
  t.integer  "school_id",      :null => false
  t.string   "school_year_id", :null => false
  t.integer  "number",         :null => false
  t.string   "letter",         :null => false
end

module School
  class Klass < ActiveRecord::Base
    include SchoolYearSerializer
  end
end

Klass.first!.school_year
Klass.new.tap{|k| k.school_year = SchoolYear.new(2015, 2015)}
Klass.where(school_year_id: SchoolYear.new(2013, 2014).id)

Language & Dictionary

Something went wrong! ;) Should be Value Objects

create_table "dictionaries", :force => true do |t|
  t.integer  "source_language_id"
  t.integer  "target_language_id"
  t.string   "source_language_shortcut"
  t.string   "target_language_shortcut"
end

create_table "languages", :force => true do |t|
  t.string   "name"
  t.string   "shortcut"
end

class Language < ActiveRecord::Base
  ALLOWED_LANGUAGE_SHORTCUTS = %w(cs da de el en es fr hu it la lb nl no pl pt ru sl sv tr zh)

  attr_readonly :shortcut
  validates :shortcut, inclusion: { in: ALLOWED_LANGUAGE_SHORTCUTS }

  scope :by_shortcut, order('shortcut')

  def as_json
    {
      shortcut: shortcut,
      name: translated_shortcut
    }
  end

  def translated_shortcut
    _('lang_' + shortcut)
  end

  def self.allowed?(shortcut)
    ALLOWED_LANGUAGE_SHORTCUTS.include?(shortcut)
  end

  def for_select
    [translated_shortcut, shortcut]
  end

  def self.german_lang_select
    [[_("Source language"), :source], [_("Target language"), :target]]
  end

  def accents
    return ['en_gb', 'en_us'] if shortcut == 'en'
    return ['pt_pt', 'pt_br'] if shortcut == 'pt'
    return [] if %w(la hu sl).include?(shortcut)
    [shortcut]
  end
end

class Dictionary < ActiveRecord::Base
  has_many :products, dependent: :destroy
  has_many :lessons, dependent: :destroy, inverse_of: :dictionary
  has_many :vocabulary_tests, class_name: "School::VocabularyTest", dependent: :destroy, inverse_of: :dictionary
  has_many :lesson_categories, through: :lessons, order: "parent_id", uniq: true
  has_many :default_lessons, class_name: "Lesson", conditions: {default: true}
  has_many :code_lessons, class_name: "Lesson", through: :products, source: :lessons
  has_many :student_dictionaries, dependent: :destroy
  has_many :student_current_dictionaries, class_name: "Student", foreign_key: :current_dictionary_id, dependent: :nullify
  has_many :students, through: :student_dictionaries
  belongs_to :source_language,
    class_name: "Language",
    primary_key: :shortcut,
    foreign_key: :source_language_shortcut
  belongs_to :target_language,
    class_name: "Language",
    primary_key: :shortcut,
    foreign_key: :target_language_shortcut

  validates_presence_of :source_language
  validates_presence_of :target_language

  delegate :name, to: :source_language, prefix: true, allow_nil: true
  delegate :name, to: :target_language, prefix: true, allow_nil: true

  def paid_products
    products.paid.includes(:lessons)
  end

  def name
    [_("lang_#{source_language_shortcut}"), _("lang_#{target_language_shortcut}")].join(" - ")
  end

  def short_name
    [source_language_shortcut, target_language_shortcut].join("-")
  end

  def mirror
    self.class.where(
      source_language_shortcut: target_language_shortcut,
      target_language_shortcut: source_language_shortcut
    ).first_or_create!
  end

  def create_lesson(params, student=nil)
    lessons.new(params).tap do |lesson|
      if student.present?
        lesson.student = student
        lesson.students << student
      end
      lesson.save
    end
  end

  def self.for_dictionary_name shortcut
    source, target = shortcut.to_s.split("-")
    for_shortcuts(source, target)
  end

  def self.for_shortcuts(src, tgt)
    src_lang = Language.find_or_create_by_shortcut(src)
    tgt_lang = Language.find_or_create_by_shortcut(tgt)
    return unless [src_lang, tgt_lang].all?(&:valid?)
    where(source_language_shortcut: src, target_language_shortcut: tgt).first_or_create
  end

  def self.default_dictionary_for(locale)
    supported_locales = ["de", "el", "es", "fr", "it", "pl", "pt", "ru", "sl", "tr"]

    if supported_locales.include?(locale.to_s)
      for_shortcuts(locale.to_s, "en")
    else
      for_shortcuts("en", "de")
    end
  end
end

Aggregates

First, AGGREGATES tighten up the model itself by defining clear ownership and boundaries, avoiding a chaotic, tangled web of objects. This pattern is crucial to maintaining integrity in all phases of the life cycle.

from "Domain Driven Design" by Eric Evans

Ownership

Boundaries

Avoid chaos

[tweet]

Cluster the ENTITIES and VALUE OBJECTS into AGGREGATES and define boundaries around each. Choose one ENTITY to be the root of each AGGREGATE, and control all access to the objects inside the boundary through the root

from "Domain Driven Design" by Eric Evans

Root

Control access

[tweet]

An AGGREGATE is a cluster of associated objects that we treat as a unit for the purpose of data changes. Each AGGREGATE has a root and a boundary. The boundary defines what is inside the AGGREGATE. The root is a single, specific ENTITY contained in the AGGREGATE. The root is the only member of the AGGREGATE that outside objects are allowed to hold references to, although objects within the boundary may hold references to each other. ENTITIES other than the root have local identity, but that identity needs to be distinguishable only within the AGGREGATE, because no outside object can ever see it out of the context of the root ENTITY

from "Domain Driven Design" by Eric Evans

Purpose of data change

The boundary defines what is inside the AGGREGATE

[tweet]

An object should be distilled until nothing remains that does not relate to its meaning or support its role in interactions.

from "Domain Driven Design" by Eric Evans

How many things do we add to our models because "Views"?

[tweet]

Changing the mindset - more object-oriented view at the business domain modeling

Billing

require "not_activerecord"
require "dependor/shorty"

#removed configuration options for clarity

create_table :school_billing_licenses, id: false do |t|
  t.string    :id
  t.integer   :school_id
  t.integer   :pupil_id
  t.datetime  :activated_at
  t.string    :native_language
  t.string    :learning_language
  t.string    :school_year_id
  t.datetime  :deactivated_at
end

create_table :school_billing_subscriptions, id: false do |t|
  t.string    :id
  t.integer   :school_id
  t.string    :native_language
  t.string    :learning_language
  t.string    :school_year_id
  t.integer   :bought_licenses
  t.integer   :used_licenses
  t.decimal   :price
end

create_table :school_billing_purchases, id: false do |t|
  t.string    :id
  t.integer   :school_id
  t.string    :native_language
  t.string    :learning_language
  t.string    :school_year_id
  t.integer   :bought_licenses
  t.decimal   :price
  t.datetime  :purchased_at
end

class Billing < ActiveRecord::Base
  class Subscription < ActiveRecord::Base
    include SchoolYearSerializer
    extend  NotActiveRecord

    does_not_belong_to :school

    def dictionary_name
      s_("School|lang_" + learning_language)
    end

    def overused?
      used_licenses > bought_licenses
    end

    def cancelable?
      bought_licenses.nonzero?
    end
  end

  class License < ActiveRecord::Base
    include SchoolYearSerializer
    extend  NotActiveRecord

    does_not_belong_to :school
    does_not_belong_to :pupil
  end

  class Purchase < ActiveRecord::Base
    include SchoolYearSerializer
    extend  NotActiveRecord

    does_not_belong_to :school
  end

  class SubscriptionPriceCalculator
    takes :school_year, :bought_licenses, :now

    def price
      raise ArgumentError if months_to_pay >= 13
      raise ArgumentError if months_to_pay <= 0

      price = price_per_license * bought_licenses * BigDecimal.new(months_to_pay) / BigDecimal.new(12)
      price.round(2, BigDecimal::ROUND_HALF_UP)
    end

    private

    def months_to_pay
      if now < school_year.starts_at
        12
      else
        (school_year.ends_at.year * 12 + school_year.ends_at.month) - (now.year * 12 + now.month) + 1
      end
    end

    def price_per_license
      BigDecimal.new("2.5")
    end
  end

  self.table_name = "schools"

  has_many :subscriptions, class_name: "::School::Billing::Subscription", foreign_key: "school_id", autosave: true
  has_many :licenses,      class_name: "::School::Billing::License",      foreign_key: "school_id", autosave: true
  has_many :purchases,     class_name: "::School::Billing::Purchase",     foreign_key: "school_id", autosave: true

  def used_licenses(pupil_ids, native_language, learning_languages, school_year, at_time)
    pupil_ids = Array.wrap(pupil_ids)
    Array.wrap(learning_languages).each do |learning_language|
      s = subscription_for(native_language, learning_language, school_year)
      s.used_licenses += pupil_ids.size
      pupil_ids.each do |pupil_id|
        licenses.build do |l|
          l.id                = SecureRandom.uuid
          l.pupil_id          = pupil_id
          l.activated_at      = at_time
          l.native_language   = native_language
          l.learning_language = learning_language
          l.school_year       = school_year
        end
      end
    end
  end

  def terminated_licenses(pupil_ids, native_language, learning_languages, school_year, at_time)
    pupil_ids = Array.wrap(pupil_ids)
    Array.wrap(learning_languages).each do |learning_language|
      s = subscription_for(native_language, learning_language, school_year)
      s.used_licenses -= pupil_ids.size
      pupil_ids.each do |pupil_id|
        license = licenses.find do |l|
          l.pupil_id == pupil_id &&
          l.native_language == native_language &&
          l.learning_language == learning_language &&
          l.school_year == school_year &&
          l.deactivated_at.nil?
        end
        license.deactivated_at = at_time
      end
    end
  end

  def subscriptions_for(native_language, learning_languages, school_years)
    Array.wrap(learning_languages).map do |ll|
      Array.wrap(school_years).map do |sy|
        subscriptions.find do |s|
          s.native_language == native_language &&
          s.learning_language == ll &&
          s.school_year == sy
        end || subscriptions.build do |s|
          s.id                = SecureRandom.uuid
          s.native_language   = native_language
          s.learning_language = ll
          s.school_year       = sy
          s.used_licenses     = 0
          s.bought_licenses   = 0
          s.price             = 0
        end
      end
    end.flatten
  end

  def subscription_for(native_language, learning_language, school_year)
    subscriptions_for(native_language, learning_language, school_year).first
  end

  PurchasingNotEnoughLicenses = Class.new(StandardError)

  def buy_subscription(native_language, learning_language, school_year, bought_licenses, at)
    s = subscription_for(native_language, learning_language, school_year)
    raise PurchasingNotEnoughLicenses if s.bought_licenses + bought_licenses < 40
    price = SubscriptionPriceCalculator.new(school_year, bought_licenses, at).price

    purchase = purchases.build do |p|
      p.id                = SecureRandom.uuid
      p.native_language   = native_language
      p.learning_language = learning_language
      p.school_year       = school_year
      p.bought_licenses   = bought_licenses
      p.price             = price
      p.purchased_at      = at
    end

    s.bought_licenses += purchase.bought_licenses
    s.price += purchase.price
    s
  end

  def cancel_subscription(native_language, learning_language, school_year, at)
    s = subscription_for(native_language, learning_language, school_year)
    purchases.build do |p|
      p.id                = SecureRandom.uuid
      p.native_language   = native_language
      p.learning_language = learning_language
      p.school_year       = school_year
      p.bought_licenses   = -s.bought_licenses
      p.price             = s.price * BigDecimal.new(-1)
      p.purchased_at      = at
    end

    s = subscription_for(native_language, learning_language, school_year)
    s.bought_licenses = 0
    s.price = 0
    s
  end

  # some domain methods removed
end

Not a single save()

Boundaries cannot cross because not_activerecord

Aggregate DB

class BillingDB
  def with_billing(school_id)
    ActiveRecord::Base.transaction do
      Billing.find(school_id).lock!
      b = Billing.preload(:licenses, :subscriptions, :purchases).find(school_id)
      value = yield b
      b.save!
      value
    end
  end
end

Plays with autosave: true. Only new/changed objects saved

Table reuse as intermediary step

class School < ActiveRecord::Base
  # does not know anything about the associations in Billing module
end

class Billing < ActiveRecord::Base
  self.table_name = "schools"

  has_many :subscriptions, class_name: "::School::Billing::Subscription", foreign_key: "school_id", autosave: true
  has_many :licenses,      class_name: "::School::Billing::License",      foreign_key: "school_id", autosave: true
  has_many :purchases,     class_name: "::School::Billing::Purchase",     foreign_key: "school_id", autosave: true
end

Billing.find(school_id)

Tests don't need to save()

context "changing licenses" do
  before do
    billing.used_licenses(111, "de", "en", sy_2012_2013, at = Time.new(2012, 10, 1) )
    billing.used_licenses([222, 333], "de", ["fr", "es"], sy_2012_2013, at = Time.new(2012, 10, 2) )
  end

  specify "terminates unused licenses for languages no longer learnt and creates new ones for new languages" do
    billing.changed_used_licenses([222], "de", %w(fr es), %w(es en), sy_2012_2013, at = Time.new(2012, 10, 3) )
    expect(billing.licenses).to have(6).elements
    expect(billing.licenses.find{|l| l.pupil_id == 222 && l.learning_language == "es" }.deactivated_at).to be_nil
    expect(billing.licenses.find{|l| l.pupil_id == 222 && l.learning_language == "fr" }.deactivated_at).to eq(at)

    license = billing.licenses[5]
    expect(license.id).to be_present
    expect(license.pupil_id).to eq(222)
    expect(license.activated_at).to eq(at)
    expect(license.native_language).to eq("de")
    expect(license.learning_language).to eq("en")

    subscription = billing.subscription_for("de", "en", sy_2012_2013)
    expect(subscription.used_licenses).to     eq(2)

    subscription = billing.subscription_for("de", "fr", sy_2012_2013)
    expect(subscription.used_licenses).to     eq(1)

    subscription = billing.subscription_for("de", "es", sy_2012_2013)
    expect(subscription.used_licenses).to     eq(2)
  end
end

In memory DB for Aggregate

class BillingInMemoryDB
  def initialize
    @billings = []
  end

  def with_billing(school_id)
    school_id = school_id.to_i
    billing   = @billings.find{|b| b.id == school_id }
    billing ||= (@billings << Billing.new.tap{|b| b.id = b.name = school_id}).last
    value = yield billing
    value
  end
end

What makes a good Unit? ;)

context "terminate licenses" do
  before do
    billing.used_licenses(111, "de", "en", sy_2012_2013, at = Time.new(2012, 10, 1) )
    billing.used_licenses([222, 333], "de", ["fr", "es"], sy_2012_2013, at = Time.new(2012, 10, 2) )
    billing.changed_used_licenses([222], "de", %w(fr es), %w(es en), sy_2012_2013, at = Time.new(2012, 10, 3) )
  end

  specify "deactives licenses and lowers their counter in subscription" do
    billing.terminated_licenses([222, 333], "de", "es", sy_2012_2013, at = Time.new(2012, 10, 4))
    expect(billing.licenses).to have(6).elements
    # ...
  end
end

Test flow, not a single method

In memory DBs tested with normal implementation

describe Test::InMemoryProgressDB do
  it_should_behave_like "ProgressDB"
end

describe ProgressDB do
  it_should_behave_like "ProgressDB"
end

shared_examples_for "ProgressDB" do
  specify "integration flow" do
    training_id = 1
    lesson_id   = 2
    student_id  = 3

    subject.save_known_training_progress_level(training_id, lesson_id, student_id, 1)

    progress = subject.find_all_by_user_and_lessons(student_id, lesson_id)
    expect(progress).to have(1).element

    element = progress.first
    expect(element.training_id).to eq(training_id)
    expect(element.lesson_id).to   eq(lesson_id)
    expect(element.student_id).to  eq(student_id)
    expect(element.level).to       eq(1)
    # ...
  end
end

Read more

Did you like this presentation? Share it with your friends!

Share on twitter

Share on Facebook

Share on Google+

Share on LinkedIn

People also liked reading from us

• 3 ways to do eager loading (preloading) in Rails 3 & 4

• Devise & User coupling

• Developers Oriented Project Management

Subscribe to our newsletter to get more content like this

Thank you