Fighting with fat models

Bogdan Gusiev

Bogdan G.

  • is 9 years in IT
  • 6 years with Ruby and Rails
    • Long Run Rails Contributor

Some of my gems

Talkable

  • 7 years old startup
  • A lot of code
  • Rails version from 2.0 to 5.1

Fat Models

Why the problem appears?

  • All business logic code goes to model by default.

In the MVC:

Why it should not be
in controller or view?

Because they are hard to:

  • reuse
  • test
  • maintain

A definition of being fat

1000 Lines of code

But it depends on:

  • Project Size
  • Docs

Talkable Example

$ wc -l app/models/* | sort -n | tail
   # 2016-01-21
   532 app/models/incentive.rb
   540 app/models/person.rb
   544 app/models/visitor_offer.rb
   550 app/models/reward.rb
   571 app/models/web_hook.rb
   786 app/models/site.rb
   790 app/models/referral.rb
   943 app/models/campaign.rb
   998 app/models/offer.rb
 14924 total
   # 2017-12-06
   585 app/models/reward.rb
   588 app/models/incentive.rb
   592 app/models/view_setup.rb
   597 app/models/web_hook.rb
   602 app/models/visitor_offer.rb
   627 app/models/referral.rb
   637 app/models/site.rb
   756 app/models/offer.rb
  1193 app/models/campaign.rb
 17831 total

TODO: move serialization out of campaign.rb

Existing techniques

  • Services
    • Separated utility class
  • Concerns
    • Modules that get included to models
  • Decorators
    • Classes that wrap existing model to plug new methods

What do we expect?

  • Standard:

    • Reusable code
    • Easy to test
    • Good API
  • Advanced:

    • Effective data model
    • More features per second
    • Data Safety

Good API

Is a user connected to facebook?

user.has_facebook_profile?
# OR
FacebookService.has_facebook_profile?(user)
# OR
FacebookWrapper.new(user).has_facebook_profile?

The need of Services

When amount of utils

that support Model goes higher

extract them to service is good idea.

Move class methods
between files is cheap

# move
User.create_from_facebook
# to
UserService.create_from_facebook
# or
FacebookService.create_user

Organise services by process

rather than object they operate on

Otherwise at some moment UserService would not be enough

The problem of services

Service is separated utility class.

module CommentService
  def self.create(attributes)
    comment = Comment.create!(attributes)
    deliver_notification(comment)
  end
end

“Я знаю откуда что берется”

Services don’t

provide default behavior

The Need of Default Behavior

Object should encapsulate behavior:

  • Data Rules
    • Set of rules that a model should fit at the programming level
      • Ex: A comment should have an author
  • Business Rules
    • Set of rules that a model should fit to exist in the real world
      • Ex: A comment should deliver an email notification

What is a model?

The model is an imitation of real object

that reflects some it’s behaviors

that we are focused on.

Wikipedia

Model

is a best place for default behaviour

MVC authors meant that

Implementation

Using built-in Rails features:

  • ActiveRecord::Callbacks

Hooks in models

We create default behavior and our data is safe.

Example: Comment can not be created without notification.

class Comment < AR::Base
  after_create :send_notification
end

API comparison

A comment should be made like this:

Comment.create
# or
CommentService.create

How to deliver the knowledge to XXX team members?

Successful Projects tend to do

one thing

in many different ways

rather than a lot of things

  • Comment on a web site
  • Comment in native mobile iOS app
  • Comment in native mobile Android app
  • Comment by replying to an email letter
  • Automatically generate comments

Edge cases

In all cases data created in regular way

In one edge cases special rules applied

Service with options

module CommentService
  def self.create(
    attrs, skip_notification = false)
end

Default behavior

and edge cases

  • Hey model, create my comment.
    • Ok
  • Hey model, why did you send the notification?
    • Because you didn't say you don't need it
  • Hey model, create model without notification
    • Ok

Support parameter in model

class Comment < AR::Base
  attr_accessor :skip_comment_notification
  after_create do
    unless self.skip_comment_notification
      send_notification
    end
  end
end

#skip_comment_notification is used only in edge cases.

Default Behaviour is hard to make

But it solves communication problems

Good Service Example

Customer.has_many :purchases
Purchase.has_many :ordered_items
OrderItem.belongs_to :product
class PhoneOrder
  includes ActiveModel::AttributesAssignment
  def initialize(attributes)
  def save!
end

PhoneOrder is only different from online one only by
how its made not by how it behaves afterwards.

Validation

Custom

class PhoneOrder
  includes ActiveModel::Validation
  validate :somthing, :somehow
  ...
end

Reused

class PhoneOrder
  def valid?
    @purchase.valid? && @customer.valid? && 
      @item.all?(&:valid?)
  end
end

What is the difference?

PhoneOrder.new(...).save!

Comment.after_create :send_notification
  • Business rules:

    • An Order could be created by phone call
    • A Comment should send an email notification




Model stands for should

Service stands for could

Please do not confuse
should with must

Service Model
Builder Holder
Workflow Static
Opportunity Protection
Custom Default
Local Universal

The model is still fat.

What to do?

Split model into Concerns

class User < AR::Base
  include FacebookProfile
end

module FacebookProfile
  has_one :facebook_profile # simplified
  def connected_to_facebook?
end

Use Concerns

class Comment < AR::Base
  include CommentNotification
  include FeedActivityGeneration
  include Archivable
end

Rails default: app/models/concerns/*

You may think of

Concerns violate SRP

And you are right!

Single Responsibility Principle

does not work

for real world models

There is no a single thing

in the universe that follows the SRP

Proton

class Proton
  include Gravitation
  include ElectroMagnetism
  include StrongNuclearForce
  include WeekNuclearForce
end

Model Concerns
are unavoidable

if you want to follow
MVC ideas

Concerns are Vertical slicing

Unlike MVC which is horizontal slicing.

Where are decorators?

user = FacebookWrapper.new(user)
# OR
class User
  include FacebookProfile
end
#connected_to_facebook?
#facebook_image
#profile_url

Trade an API for less methods in object

Good Decorators Example

class SiteDashboardController
  def show
    @site = Site.find(params[:id])
    @dashboard = SiteDashboard.new(@site)
  end
end

When decoration is effective?

  • Situational API
  • Wrapping around multiple objects
  • Wrapping around collection of objects
  • Under-the-hood class

Datagrid Gem

Example of collection wrapper

https://github.com/bogdan/datagrid

UsersGrid.new(
  last_request: Date.today, 
  created_at: 1.month.ago..Time.now)

class UsersGrid
  scope { User }

  filter(:created_at, :date, range: true)
  filter(:last_request_at, :datetime, range: true)
  filter(:ip_address, :string)
    
  column(:id)
  column(:email)
  column(:last_request_at)
end

Wrapping Around
Built-in object

https://github.com/bogdan/furi

u = Furi.parse(
  "http://bogdan.github.com/index.html")
u.subdomain # => 'bogdan'
u.extension # => 'html'
u.ssl?      # => false

module Furi
  def self.parse(string)
    Furi::Uri.new(string).parse
  end
end

Ex.1 User + Facebook

has_one :facebook_profile Easy => Model
register_user_from_facebook Could => Service
connect_facebook_profile Could => Service
connected_to_facebook? Should => Model
  • Every user should know if it is connected to facebook or not

Ex.2 Deliver comment notification

  • Comment #send_notification => Model
    • Default Behaviour
    • Even if exceptions exist

Concerns Base

  • Attributes
  • Associations
    • has_one
    • has_many
    • has_and_belongs_to_many

But rarely

    • belongs_to

Libraries using Concerns

  • ActiveRecord
  • ActiveModel
  • Devise
  • Datagrid

If it is possible for such a complicated library

then it is easy for regular projects

Summary

Basic application architecture

View
Controller
Services Decorators
Model
Concern Concern Concern Concern

Inject Service between Model and Controller

if you need them

Could? => Service

Should? => Model

SRP is a misleading principle in the MVC

It should not inhibit you from having

a Better Application Model

Fat models => Thin Concerns

Decorators

are for situaltional APIs
in really large projects

Ruby is all you need

All promising gems don't do much

ActiveModel is the only real helper

The End

Thanks for your time

http://gusiev.com

https://github.com/bogdan

Buktopuha

How many object are made here and name them?

2.times do |i|
  :hello.to_s
end

How many SQL queries (including cached) is generated here?

Campaign.has_many :localizations

def localize_by_key(key)
  @campaign.localizations.preload(:variants).find do |l|
    l.key == key
  end.text
end
= localize_by_key("offer_title")
= localize_by_key("offer_description")

Rails v3

Post.has_many :comments, order("created_at desc")

Rails v4

Post.has_many :comments, -> { order("created_at desc") }

The code that uploads toxic assets to FTP every day

Why is the date passet to worker as argument?

# Runs daily via crontab
task :upload_toxic_asset_to_ftp => :environment do
  ToxicUploaderWorker.perform_async(Date.today)
end

class ToxicUploaderWorker
  def perform(date)
    file_name = "toxic-assets-#{date}.csv"
    upload_to_ftp(file_name, to_csv(Asset.where(created_at: date)))
  end
end