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