├── README.md ├── Gemfile ├── Rakefile ├── 4-query-objects ├── 4_relation_composition.rb ├── 3_import_accounts_job.rb ├── 2_importable_accounts_query.rb └── 1_account.rb ├── 1-value-objects ├── 3_constant.rb ├── 1_constant.rb └── 2_rating.rb ├── 5-view-objects ├── 3_dashboard.html.slim ├── 3_dashboard_controller.rb ├── 1_user.rb └── 2_onboarding_steps.rb ├── 7-decorators ├── 2_order_email_notifier.rb ├── 1_order.rb └── 3_orders_controller.rb ├── 3-form-objects ├── 3_signups_controller.rb ├── 1_user.rb └── 2_signup.rb ├── 6-policy-objects ├── 1_user.rb └── 2_email_notification_policy.rb ├── 2-service-objects ├── 2_password_authenticator.rb ├── 3_sessions_controller.rb ├── 2_token_authenticator.rb └── 1_user.rb └── Gemfile.lock /README.md: -------------------------------------------------------------------------------- 1 | refactoring-fat-models 2 | ====================== -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "rspec" 4 | gem "rake" 5 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rspec/core/rake_task' 2 | 3 | RSpec::Core::RakeTask.new(:spec) 4 | 5 | task default: :spec 6 | -------------------------------------------------------------------------------- /4-query-objects/4_relation_composition.rb: -------------------------------------------------------------------------------- 1 | old_accounts = Account.where("created_at < ?", 1.month.ago) 2 | ImportableAccountsQuery.new(old_accounts) 3 | -------------------------------------------------------------------------------- /1-value-objects/3_constant.rb: -------------------------------------------------------------------------------- 1 | class Constant < ActiveRecord::Base 2 | # … 3 | 4 | def rating 5 | @rating ||= Rating.from_cost(cost) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /5-view-objects/3_dashboard.html.slim: -------------------------------------------------------------------------------- 1 | h1 Dashboard 2 | 3 | .onboarding 4 | p.message= @onboarding_steps.message 5 | .progress_meter #{@onboarding.progress_percent}% 6 | 7 | / ... 8 | -------------------------------------------------------------------------------- /5-view-objects/3_dashboard_controller.rb: -------------------------------------------------------------------------------- 1 | class DashboardsController < ApplicationController 2 | # ... 3 | 4 | def show 5 | # ... 6 | @onboarding_steps = OnboardingSteps.new(current_user) 7 | end 8 | 9 | end 10 | -------------------------------------------------------------------------------- /4-query-objects/3_import_accounts_job.rb: -------------------------------------------------------------------------------- 1 | class ImportAccountsJob < Job 2 | def run 3 | query = ImportableAccountsQuery.new 4 | query.find_each do |account| 5 | AccountImporter.new(account).run 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /7-decorators/2_order_email_notifier.rb: -------------------------------------------------------------------------------- 1 | class OrderEmailNotifier 2 | def initialize(order) 3 | @order = order 4 | end 5 | 6 | def save 7 | @order.save && 8 | OrdersMailer.receipt(@order).deliver 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /7-decorators/1_order.rb: -------------------------------------------------------------------------------- 1 | class Order < ActiveRecord::Base 2 | # ... 3 | attr_accessor :placed_online 4 | 5 | after_create :email_receipt, if: :placed_online 6 | 7 | private 8 | 9 | def email_receipt 10 | OrdersMailer.receipt(self).deliver 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /3-form-objects/3_signups_controller.rb: -------------------------------------------------------------------------------- 1 | class SignupsController < ApplicationController 2 | def create 3 | @signup = Signup.new(params[:signup]) 4 | 5 | if @signup.save 6 | redirect_to dashboard_path 7 | else 8 | render "new" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /6-policy-objects/1_user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | # ... 3 | 4 | def deliver_notification?(notification_type, project = nil) 5 | !hard_bounce? && 6 | receive_notification_type?(notification_type) && 7 | (!project || receives_notifications_for?(project)) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /2-service-objects/2_password_authenticator.rb: -------------------------------------------------------------------------------- 1 | class PasswordAuthenticator 2 | def initialize(user) 3 | @user = user 4 | end 5 | 6 | def authenticate(unencrypted_password) 7 | @user && bcrypt_password == unencrypted_password 8 | end 9 | 10 | private 11 | 12 | def bcrypt_password 13 | BCrypt::Password.new(@user.password_digest) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /3-form-objects/1_user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | attr_accessor :company 3 | 4 | belongs_to :company 5 | 6 | attr_accessible :company, :name, :email 7 | before_create :create_company 8 | 9 | validates :company, length: { minimum: 3 }, on: :create 10 | 11 | def create_company 12 | self.company = Company.create!(name: read_attribute(:company)) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /2-service-objects/3_sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | def create 3 | user = User.find_by_email(params[:email]) 4 | authenticator = PasswordAuthenticator.new(user) 5 | 6 | if authenticator.authenticate(params[:password]) 7 | self.current_user = user 8 | redirect_to dashboard_path 9 | else 10 | redirect_to login_path, alert: "Login failed." 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | diff-lcs (1.1.3) 5 | rake (10.0.3) 6 | rspec (2.12.0) 7 | rspec-core (~> 2.12.0) 8 | rspec-expectations (~> 2.12.0) 9 | rspec-mocks (~> 2.12.0) 10 | rspec-core (2.12.2) 11 | rspec-expectations (2.12.1) 12 | diff-lcs (~> 1.1.3) 13 | rspec-mocks (2.12.2) 14 | 15 | PLATFORMS 16 | ruby 17 | 18 | DEPENDENCIES 19 | rake 20 | rspec 21 | -------------------------------------------------------------------------------- /5-view-objects/1_user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | # ... 3 | 4 | def onboarding_message 5 | if !confirmed_email? 6 | "Make sure to confirm your email address." 7 | elsif !sent_invites? 8 | "Be sure to add your friends!" 9 | end 10 | end 11 | 12 | def onboarding_progress_percent 13 | score = 0 14 | score += 1 if @user.confirmed_email? 15 | score += 1 if @user.sent_invites? 16 | (score / 2.0 * 100) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /4-query-objects/2_importable_accounts_query.rb: -------------------------------------------------------------------------------- 1 | class ImportableAccountsQuery 2 | def initialize(relation = Account.scoped) 3 | @relation = relation 4 | end 5 | 6 | def find_each(&block) 7 | @relation. 8 | where(enabled: true). 9 | where("failed_attempts_count <= 3"). 10 | joins("LEFT JOIN imports ON account_id = accounts.id"). 11 | order('last_attempt_at ASC'). 12 | preload(:credentials). 13 | find_each(&block) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /5-view-objects/2_onboarding_steps.rb: -------------------------------------------------------------------------------- 1 | class OnboardingSteps 2 | def initialize(user) 3 | @user = user 4 | end 5 | 6 | def message 7 | if !@user.confirmed_email? 8 | "Make sure to confirm your email address." 9 | elsif !@user.sent_invites? 10 | "Be sure to add your friends!" 11 | end 12 | end 13 | 14 | def progress_percent 15 | score = 0 16 | score += 1 if @user.confirmed_email? 17 | score += 1 if @user.sent_invites? 18 | (score / 2.0 * 100) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /2-service-objects/2_token_authenticator.rb: -------------------------------------------------------------------------------- 1 | class TokenAuthenticator 2 | def initialize(user) 3 | @user = user 4 | end 5 | 6 | def authenticate(provided_token) 7 | return false if !@user || !@user.api_token? 8 | secure_compare(@user.api_token, provided_token) 9 | end 10 | 11 | private 12 | 13 | def secure_compare(a, b) 14 | return false unless a.bytesize == b.bytesize 15 | l = a.unpack "C#{a.bytesize}" 16 | res = 0 17 | b.each_byte { |byte| res |= byte ^ l.shift } 18 | res == 0 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /7-decorators/3_orders_controller.rb: -------------------------------------------------------------------------------- 1 | class OrdersController < ApplicationController 2 | def create 3 | @order = build_order 4 | 5 | if @order.save 6 | redirect_to orders_path, notice: "Your order was placed." 7 | else 8 | render "new" 9 | end 10 | end 11 | 12 | private 13 | 14 | def build_order 15 | order = Order.new(params[:order]) 16 | order = WarehouseNotifier.new(order) 17 | order = OrderEmailNotifier.new(order) 18 | order 19 | end 20 | 21 | end 22 | 23 | 24 | # Can you read this? 25 | -------------------------------------------------------------------------------- /6-policy-objects/2_email_notification_policy.rb: -------------------------------------------------------------------------------- 1 | class EmailNotificationPolicy 2 | def initialize(user, notification_type, project = nil) 3 | @user = user 4 | @notification_type = notification_type 5 | @project = project 6 | end 7 | 8 | def deliver? 9 | !@user.hard_bounce? && 10 | @user.receive_notification_type?(notification_type) && 11 | receives_project_emails? 12 | end 13 | 14 | private 15 | 16 | def receives_project_emails? 17 | !@project || 18 | @user.receives_notifications_for?(@project) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /1-value-objects/1_constant.rb: -------------------------------------------------------------------------------- 1 | class Constant < ActiveRecord::Base 2 | # ... 3 | 4 | def worse_rating 5 | if rating_string == "F" 6 | nil 7 | else 8 | rating_string.succ 9 | end 10 | end 11 | 12 | def rating_higher_than?(other_rating) 13 | rating_string > other_rating.rating_string 14 | end 15 | 16 | def rating_string 17 | if remediation_cost <= 2 then "A" 18 | elsif remediation_cost <= 4 then "B" 19 | elsif remediation_cost <= 8 then "C" 20 | elsif remediation_cost <= 16 then "D" 21 | else "F" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /4-query-objects/1_account.rb: -------------------------------------------------------------------------------- 1 | class Account < ActiveRecord::Base 2 | # ... 3 | 4 | def self.importable_accounts 5 | where(enabled: true). 6 | where("failed_attempts_count <= 3"). 7 | joins("LEFT JOIN import_attempts ON account_id = accounts.id"). 8 | order('last_attempt_at ASC'). 9 | preload(:credentials) 10 | end 11 | 12 | def self.import_failed_accounts 13 | where("failed_attempts_count >= 3") 14 | joins("LEFT JOIN import_attempts ON account_id = accounts.id") 15 | order('failed_attempts_count DESC'). 16 | preload(:credentials) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /2-service-objects/1_user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | # ... 3 | 4 | def password_authenticate(unencrypted_password) 5 | BCrypt::Password.new(password_digest) == unencrypted_password 6 | end 7 | 8 | def token_authenticate(provided_token) 9 | secure_compare(api_token, provided_token) 10 | end 11 | 12 | private 13 | 14 | # constant-time comparison algorithm to prevent timing attacks 15 | def secure_compare(a, b) 16 | return false unless a.bytesize == b.bytesize 17 | l = a.unpack "C#{a.bytesize}" 18 | res = 0 19 | b.each_byte { |byte| res |= byte ^ l.shift } 20 | res == 0 21 | end 22 | 23 | end 24 | -------------------------------------------------------------------------------- /1-value-objects/2_rating.rb: -------------------------------------------------------------------------------- 1 | class Rating 2 | include Comparable 3 | 4 | def self.from_cost(cost) 5 | if cost <= 2 then new("A") 6 | elsif cost <= 4 then new("B") 7 | elsif cost <= 8 then new("C") 8 | elsif cost <= 16 then new("D") 9 | else new("F") 10 | end 11 | end 12 | 13 | def initialize(letter) 14 | @letter = letter 15 | end 16 | 17 | def to_s 18 | @letter.to_s 19 | end 20 | 21 | def <=>(other) 22 | other.to_s <=> to_s 23 | end 24 | 25 | def worse 26 | return nil if @letter == "F" 27 | self.class.new(@letter.succ) 28 | end 29 | 30 | def higher_than?(other) 31 | self > other 32 | end 33 | 34 | def hash 35 | @letter.hash 36 | end 37 | 38 | def eql?(other) 39 | to_s == other.to_s 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /3-form-objects/2_signup.rb: -------------------------------------------------------------------------------- 1 | class Signup 2 | include Virtus 3 | 4 | extend ActiveModel::Naming 5 | include ActiveModel::Conversion 6 | include ActiveModel::Validations 7 | 8 | attr_reader :user 9 | attr_reader :company 10 | 11 | attribute :name, String 12 | attribute :email, String 13 | attribute :company_name, String 14 | 15 | validates :email, presence: true 16 | # … more validations … 17 | 18 | # Forms are never themselves persisted 19 | def persisted? 20 | false 21 | end 22 | 23 | def save 24 | if valid? 25 | persist! 26 | true 27 | else 28 | false 29 | end 30 | end 31 | 32 | private 33 | 34 | def persist! 35 | @company = Company.create!(name: company_name) 36 | @user = @company.users.create!(name: name, email: email) 37 | end 38 | end 39 | --------------------------------------------------------------------------------