robert @pankowecki
DDD by Evans
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
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
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")
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
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)
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
from "Domain Driven Design" by Eric Evans
from "Domain Driven Design" by Eric Evans
from "Domain Driven Design" by Eric Evans
from "Domain Driven Design" by Eric Evans
Changing the mindset - more object-oriented view at the business domain modeling
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
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
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)
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
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
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
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