We'll be mocking up a todo app for our example.
Models:
Todo(id: int, title: string, owner_email: string)
Item(id: int, todo_id: int, body: string, active: boolean)
For example, the data could be something like:
Todo(1, "Weekend Todo List", "name@example.com")
Item(1, 1, "Do laundry", true)
Item(2, 1, "Walk the dog", true)
Item(3, 1, "Go for a run", false)
Business Logic: Business logic is just rules of how your app behaves. Here are some we will require from our app:
- A todo must have at least one item (you can't have an empty todo list). This means you have to create a todo list with at least one thing on it
- Whenever an item is added to an existing todo, send an email to the owner.
- Whenever a todo is created, send an email to the owner.
Services wrap business logic that is complex. In this example, you have a all_valid? and run! method, that is dependent on an instance variable @todo_data.
class TodosImporter
def initialize(todos_data)
@todos_data = todos_data
end
def all_valid?
@todos_data.all? { |u| is_valid?(u) }
end
def run!
@todos_data.each do |todo_data|
Todo.create!(todo_data)
end
end
endSee official docs here
Actions wrap business logic that is straight-forward. In this example, we just create a todo with an item that sends an email.
class CreateTodoWithItem
extends LightService::Action
expects :todo_params, :first_item_body
promises :todo, :first_item
executed do |context|
context.todo = Todo.new(context.todo_params)
context.first_item = Item.new(todo: context.todo, body: context.first_item_body, active: true)
if !context.todo.save
context.fail!("Failed to save")
end
end
end
class SendCreatedEmailToOwner
extends LightService::Action
expects :todo
executed do |context|
TodoMailer.deliver_now(todo, "Todo Created!")
end
end
class CreateTodo
extends LightService::Organizer
def self.call(todo_params, first_item_body)
with(todo_params: todo_params, first_item_body: first_item_body).reduce([CreateTodoWithItem, SendCreatedEmailToOwner])
endModel code should only deal with validating data and loading data. It should not try to do business logic-y stuff.
class Todo < ApplicationRecord
has_many :items
validates_presence_of :title
end
class Item < ApplicationRecord
belongs_to :todo
validates_presence_of :body
end
# Bad
class Todo < ApplicationRecord
has_many :items
validates_presence_of :title
# This is the boundary between two models, it should be done in a action or service
def create_item(body)
Item.create!(todo: self, body: body)
end
endView Models are a way to group view specific helpers.
For example, maybe we want to render the title of our todo like: "{title} ({number_of_active_items})"
# Good
class TodoViewHelper
def initialize(todo)
@todo = todo
@items = todo.items
end
def title_with_items_count
"#{@todo.title} (#{@items.count})"
end
end
# To use this
@view_model = TodoViewModel.new(todo: @todo)
# Bad
class Todo < ApplicationRecord
def title_with_items_count
"#{title} (#{items.count})"
end
endWhy is this bad? Because title_with_items_count isn't really a model attribute, and this kind of code tends to blow up a file. Count the lines in user.rb if you don't believe me! It is also a presentation concern, not a persistence issue. If some other view wants to display this slightly differently, you would then add add a second method to the model, and so on.
Another common practice is to put this in a Helper.
# Bad
# app/helpers/todo_helper.rb
module TodoHelper
def title_with_items_count(todo)
"#{todo.title} (#{todo.items.count})"
end
endHowever, this is also bad because
a) it's hard to know where title_with_items_count is defined
b) Someone else could define another helper with the same name in some other Helper module and it would be hard to debug
c) All helpers are mixed into every view, increasing memory pressure
See official docs here
Draper will be used to decorate model with attributes that aren't really useful for business logic, but are useful for views.
For example, maybe I want something like item.active_string which returns either "Active" or "Inactive"
# Good
class TodoDecorator < Draper::Decorator
delegate_all
def active_string
if active?
"Active (Last updated #{updated_at_time})"
else
"Inactive"
end
end
def updated_at_time
updated_at.strftime("%A, %B %e")
end
end
# Bad: Putting it directly into the model
class Todo < ApplicationRecord
def active_string
if active?
"Active (Last updated #{updated_at_time})"
else
"Inactive"
end
end
def updated_at_time
updated_at.strftime("%A, %B %e")
end
endMost of the rails community has turned against using hooks, you probably shouldn't use them except as validators
class Todo < ApplicationModel
validate :validate_title_is_uppercase
def validate_title_is_uppercase
if self.title.first != self.title.first.upcase
errors.add(:title, "not uppercased")
end
end
end
# Bad
class Todo < ApplicationModel
after_save :send_email
def send_email
TodoMailer.send(self)
end
endWhy is this bad? Because it becomes harder and harder to test this Todo model without a bunch of stuff happening.
Helpers are really bad. They kinda just dump code into a view, and it's really hard to figure out where its defined. They shoudl only be used for really stateless, generic formatting stuff. For example:
# app/helpers/todo_helper.rb
module TodoHelper
def format_datetime(datetime)
datetime.strftime("%Y:%m:%d, %H:%M:%S") # Always format it like year, month, day hour, minute second
end
endIf you are thinking of making a helper module, here are a few questions:
- Is it reading from instance variables? Please just pass them in as params
# Bad
def todos_count_minus_one
@todos.count - 1
end
# Good
def todos_count_minus_one(todos)
todos.count - 1
end- Is it specific to a view? Just put them into view models!
# Bad
module ArticlesHelper
def article_titles_with_id(articles)
articles.map { |a| "#{a.id} #{a.title}" }
end
end
# Good
class ArticlesIndexViewModel
def initialize(articles)
@articles = articles
end
def article_titles_with_id
@articles.map { |a| "#{a.id} #{a.title}" }
end
end