Skip to content

Instantly share code, notes, and snippets.

@csivanich
Last active August 29, 2015 13:57
Show Gist options
  • Save csivanich/9417538 to your computer and use it in GitHub Desktop.
Save csivanich/9417538 to your computer and use it in GitHub Desktop.
Notes on Refactoring Fat Models With Patterns (Youtube links inside)

Refactoring Fat Models With Patterns

Youtube Link to Video

Github Examples

CodeClimate

@brynary

Pro - Simplicity Con - Tight coupling between objects and schema

Ravioli code

  • Independent pieces of code working together to feed you dinner

Calzone code

  • You can only eat one of them, and you're not going to feel very good afterwards

God objects

  • High level domain objects which do almost everything
  • Order
  • App

"Skinny controllers - fat models"

-Thought that thin controllers and fat models full of the business logic is the correct way to use AR.

"Skinny controllers - skinny models"

-Providing skinny controllers with small modular ARs and small collaborative objects working together.

  1. Value Objects

  • Small encapsulated objects
  • Equality based on value, not identity
  • Usually immutable
class Constant < ActiveRecord::Base
  # ...

  def worse_rating
    if rating_string == "F"
      nil
    else
      rating_string.succ
    end
  end

  def rating_higher_than?(other_rating)
    rating_string > other_rating.rating_string
  end

  def rating_string
    if remediation_cost <= 2 then "A"
    elsif remediation_cost <= 4 then "B"
    elsif remediation_cost <= 8 then "C"
    elsif remediation_cost <= 16 then "D"
    else "F"
    end
  end
end
  • Tying the constant into the AR class binds it to that use
  • Unable to use the Constant class outside of the AR context

Create a Rating value object instead:

class Rating
  include Comparable

  def self.from_cost(cost)
    if cost <= 2 then new("A")
    elsif cost <= 4 then new("B")
    elsif cost <= 8 then new("C")
    elsif cost <= 16 then new("D")
    else new("F")
    end
  end

  def initialize(letter)
    @letter = letter
  end

  def to_s
    @letter.to_s
  end
  • Allows comparison between other Ratings.
  • Easy to use within AR, just use a method to return the value object
class Constant < ActiveRecord::Base
  # …

  def rating
    @rating ||= Rating.from_cost(cost)
  end
end

When to use?

  • Find yourself using lots of primitives "primitive obsession"
  1. Service Objects

  • Objects which provide a standalone operation
  • Short lifecycle
  • May be stateless
class User < ActiveRecord::Base
  # ...

  def password_authenticate(unencrypted_password)
    BCrypt::Password.new(password_digest) == unencrypted_password
  end

  def token_authenticate(provided_token)
    secure_compare(api_token, provided_token)
  end

private

  # constant-time comparison algorithm to prevent timing attacks
  def secure_compare(a, b)
    return false unless a.bytesize == b.bytesize
    l = a.unpack "C#{a.bytesize}"
    res = 0
    b.each_byte { |byte| res |= byte ^ l.shift }
    res == 0
  end

end

User class with 2 different ways to authenticate. Either BCrypt or a secure Token.

What is User::secure_compare?

  • Why does the user know how to do this comparison?

Solution

  • Service object to do the comparison when necessary
  • One for each method necessary
class PasswordAuthenticator
  def initialize(user)
    @user = user
  end

  def authenticate(unencrypted_password)
    @user && bcrypt_password == unencrypted_password
  end

private

  def bcrypt_password
    BCrypt::Password.new(@user.password_digest)
  end
end
class TokenAuthenticator
  def initialize(user)
    @user = user
  end

  def authenticate(provided_token)
    return false if !@user || [email protected]_token?
    secure_compare(@user.api_token, provided_token)
  end

private

  def secure_compare(a, b)
    return false unless a.bytesize == b.bytesize
    l = a.unpack "C#{a.bytesize}"
    res = 0
    b.each_byte { |byte| res |= byte ^ l.shift }
    res == 0
  end
end
  • Simplified model
  • Only relevent code is used
  • Opt-in behavior instead of opt-out

When to use?

  • Multiple strategies
  • Complex business logic
  • Coordinating multiple models
  • External service usage
  • Ancellary methods (not important enough to be in model)
  1. Form Objects

  • One form, multiple models
  • Best used in create or update flows
class User < ActiveRecord::Base
  attr_accessor :company

  belongs_to :company

  attr_accessible :company, :name, :email
  before_create :create_company

  validates :company, length: { minimum: 3 }, on: :create

  def create_company
    self.company = Company.create!(name: read_attribute(:company))
  end
end
  • Company is kind of a shape shifter method
  • Not clear what happens if the user creation fails after the company
  • How do we create only a user?

Solution: Form Object:

class Signup
  include Virtus

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations

  attr_reader :user
  attr_reader :company

  attribute :name, String
  attribute :email, String
  attribute :company_name, String

  validates :email, presence: true
  # … more validations …
   def persisted?
    false
  end

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

private

  def persist!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(name: name, email: email)
  end
end

And the controller to use it:

class SignupsController < ApplicationController
  def create
    @signup = Signup.new(params[:signup])

    if @signup.save
      redirect_to dashboard_path
    else
      render "new"
    end
  end
end
  • Layers aggregation onto individual objects
  • Limited responsibility of AR models
  • Contextual validation

When to use?

  1. Query Objects

Encapsulate a single way to query your database

class Account < ActiveRecord::Base
  # ...

  def self.importable_accounts
    where(enabled: true).
      where("failed_attempts_count <= 3").
      joins("LEFT JOIN import_attempts ON account_id = accounts.id").
      order('last_attempt_at ASC').
      preload(:credentials)
  end

  def self.import_failed_accounts
    where("failed_attempts_count >= 3")
      joins("LEFT JOIN import_attempts ON account_id = accounts.id")
      order('failed_attempts_count DESC').
      preload(:credentials)
  end
end
  • Need to run complex queries within model
  • Some duplication in queries
  • Do we really want to see a lot of methods with SQL?
  • SQL interrupts flow and nests another language in another

Solution: Break the queries into individual objects

class ImportableAccountsQuery
  def initialize(relation = Account.scoped)
    @relation = relation
  end

  def find_each(&block)
    @relation.
      where(enabled: true).
      where("failed_attempts_count <= 3").
      joins("LEFT JOIN imports ON account_id = accounts.id").
      order('last_attempt_at ASC').
      preload(:credentials).
      find_each(&block)
  end
end
class ImportAccountsJob < Job
  def run
    query = ImportableAccountsQuery.new
    query.find_each do |account|
      AccountImporter.new(account).run
    end
  end
end

Composition working with query objects:

old_accounts = Account.where("created_at < ?", 1.month.ago)
ImportableAccountsQuery.new(old_accounts)
  • Lets AR focus on what the domain model needs to represent
  • Composable
  • First class objects encourages refactoring
  • Very little risk in refactoring

When to use?

  • Many scopes
  • Complex scopes
  • Rarely used scopes
  1. View Objects

Objects that back up a template

  • 0 to N model dependencies
class User < ActiveRecord::Base
  # ...

  def onboarding_message
    if !confirmed_email?
      "Make sure to confirm your email address."
    elsif !sent_invites?
      "Be sure to add your friends!"
    end
  end

  def onboarding_progress_percent
    score = 0
    score += 1 if @user.confirmed_email?
    score += 1 if @user.sent_invites?
    (score / 2.0 * 100)
  end
end
  • Repeated word in method name
  • Should trigger look into refactor
class OnboardingSteps
  def initialize(user)
    @user = user
  end

  def message
    if [email protected]_email?
      "Make sure to confirm your email address."
    elsif [email protected]_invites?
      "Be sure to add your friends!"
    end
  end

  def progress_percent
    score = 0
    score += 1 if @user.confirmed_email?
    score += 1 if @user.sent_invites?
    (score / 2.0 * 100)
  end
end

Html:

h1 Dashboard

.onboarding
  p.message= @onboarding_steps.message
  .progress_meter #{@onboarding.progress_percent}%

/ ...

Controller:

class DashboardsController < ApplicationController
  # ...

  def show
    # ...
    @onboarding_steps = OnboardingSteps.new(current_user)
  end

end
  • Provide way to encapsulate template into object
  • Replace helpers with objects
  • First class objects encourages refactoring

When to use?

  • Display logic in the model
  • Delivery mechanism dependent code (voice vs internet order)
  • Partials without object backing up
  1. Policy Objects

Encapsulate single business rule within object

When is a user going to recieve an email?

  • Difficult to tell
  • Not always cared about
class User < ActiveRecord::Base
  # ...

  def deliver_notification?(notification_type, project = nil)
    !hard_bounce? &&
    receive_notification_type?(notification_type) &&
    (!project || receives_notifications_for?(project))
  end
end

Policy class:

class EmailNotificationPolicy
  def initialize(user, notification_type, project = nil)
    @user = user
    @notification_type = notification_type
    @project = project
  end

  def deliver?
    [email protected]_bounce? &&
    @user.receive_notification_type?(notification_type) &&
    receives_project_emails?
  end

private

  def receives_project_emails?
    !@project ||
    @user.receives_notifications_for?(@project)
  end
end
  • Clear place to go to find out about a policy for a user
  • Easy to compose and refactor

When to use?

  • Complex reads
  • Conditional with 4 statements
  • Ancellary reads

Decorators

Object which layers behavior

Order needs to send in receipt, only if the order is placed online.

class Order < ActiveRecord::Base
  # ...
  attr_accessor :placed_online

  after_create :email_receipt, if: :placed_online

private

  def email_receipt
    OrdersMailer.receipt(self).deliver
  end
end

The callback isn't required by the object, it's only used sometimes.

class OrderEmailNotifier
  def initialize(order)
    @order = order
  end

  def save
    @order.save &&
    OrdersMailer.receipt(@order).deliver
  end
end
class OrdersController < ApplicationController
  def create
    @order = build_order

    if @order.save
      redirect_to orders_path, notice: "Your order was placed."
    else
      render "new"
    end
  end

private

  def build_order
    order = Order.new(params[:order])
    order = WarehouseNotifier.new(order)
    order = OrderEmailNotifier.new(order)
    order
  end

end
  • Separated arrangement from work
  • One object to create objects, one for work
  • Behaviors are first class concepts
  • Opt in instead of out

When to use?

  • External service calls
  • Contextual behaviors
  • Sometimes in views

Final thoughts

  • Your application should have a scaled mix of architecture and complexity.
  • Scale as smoothly as possible
  • Keep your domain well built, but NEVER overbuilt
  • If you have the right objects, it doesn't matter where you put them

Written with StackEdit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment