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
-Thought that thin controllers and fat models full of the business logic is the correct way to use AR.
-Providing skinny controllers with small modular ARs and small collaborative objects working together.
- 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
- Find yourself using lots of primitives "primitive obsession"
- 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
- Multiple strategies
- Complex business logic
- Coordinating multiple models
- External service usage
- Ancellary methods (not important enough to be in model)
- 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
- 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
- Many scopes
- Complex scopes
- Rarely used scopes
- 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
- Display logic in the model
- Delivery mechanism dependent code (voice vs internet order)
- Partials without object backing up
- 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
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
- External service calls
- Contextual behaviors
- Sometimes in views
- 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.