Slide 1

Slide 1 text

Exploring Rails Anti-Patterns Elle Meredith [email protected]

Slide 2

Slide 2 text

• Controllers • Views • Models • Requests What’s the plan doc?

Slide 3

Slide 3 text

Our little shop app

Slide 4

Slide 4 text

Common approaches to recurring problems that ultimately prove to be ineffective What are anti-patterns?

Slide 5

Slide 5 text

Common approaches to recurring problems that ultimately prove to be ineffective What are anti-patterns? Improving the quality of code without changing behaviour What is refactoring?

Slide 6

Slide 6 text

Fat controllers

Slide 7

Slide 7 text

Overloading controllers with logic # app/controllers/orders_controller.rb class OrdersController < ApplicationController def placed @order = current_object.is_a?(Cart) ? current_object.convert_to_order! : current_object @order.cancel_payment_processing! @order.place! if @order.new? && @order.balances? unless @order.confirmation_sent? OrderMailer.deliver_confirmation(order_id: @order.id) @order.update_attribute(:confirmation_sent, true) end update_session! end end

Slide 8

Slide 8 text

Solution: move business logic out to models, presenters, and service objects # app/services/place_order.rb class PlaceOrder attr_accessor :order def initialize(order) @order = if order.is_a?(Cart) ? convert_to_order! : order end def call if place! # send confirmation email end end private def place! if order.new? && order.balances? # save end end end

Slide 9

Slide 9 text

Solution: move business logic out to models, presenters, and service objects # app/controllers/orders_controller.rb class OrdersController < ApplicationController def create placed_order = PlaceOrder.new(@order).call update_session! # handle the response end end

Slide 10

Slide 10 text

Fat controllers # app/controllers/users_controller.rb class UsersController < ApplicationController def customers @users = User.all if not params[:operation].nil? then if (params[:operation] == "reset_password") then user = User.find(params[:id]) user.generate_password_reset_access_key user.password_confirmation = user.password user.email_confirmation = user.email user.save! flash[:notice] = user.first_name + " " + user.last_name + "'s password has been reset." end end end end Q: what are the problems with this code?

Slide 11

Slide 11 text

Fat controllers # app/controllers/users_controller.rb class UsersController < ApplicationController def customers @users = User.all if not params[:operation].nil? then if (params[:operation] == "reset_password") then user = User.find(params[:id]) user.generate_password_reset_access_key user.password_confirmation = user.password user.email_confirmation = user.email user.save! flash[:notice] = user.first_name + " " + user.last_name + "'s password has been reset." end end end end Q: what are the problems with this code?

Slide 12

Slide 12 text

Solution: embrace REST # app/controllers/users_controller.rb class UsersController < ApplicationController def index @users = User.active end end # app/controllers/password_resets_controller.rb class PasswordResetsController < ApplicationController def new # display a form to enter a new password end def create reset_password = ResetPassword.new(params[:user_id]).call # handle result end end

Slide 13

Slide 13 text

Spaghetti SQL — long queries # app/controllers/orders_controller.rb class OrdersController < ApplicationController def show order = Order.where(id: params[:id], customer: current_user).first line_items = LineItem .where(order: order, name: "Computer") .or(LineItem.where(order: order, name: "Phone")) render locals: {order: order, line_items: line_items} end end

Slide 14

Slide 14 text

Spaghetti SQL — long queries # app/controllers/orders_controller.rb class OrdersController < ApplicationController def show order = Order.where(id: params[:id], customer: current_user).first line_items = LineItem .where(order: order, name: "Computer") .or(LineItem.where(order: order, name: "Phone")) render locals: {order: order, line_items: line_items} end end

Slide 15

Slide 15 text

Spaghetti SQL — long queries # app/views/orders/show.html.erb

Line items↙h3> <% line_items = LineItem .where(order: @order, name: "Computer") .or(LineItem.where(order: @order, name: "Phone")) %>
    <%- line_items.each do |line_item| %>
  • <%= line_item.name %>↙li> <%- end %> ↙ul>

Slide 16

Slide 16 text

Solution: utilise ActiveRecord queries better # app/models/line_item.rb class LineItem < ApplicationRecord def self.of_interest where(name: "Computer").or(where(name: "Phone")) end end # app/controllers/orders_controller.rb class OrdersController < ApplicationController def show order = current_user.orders.where(id: params[:id]).first line_items = order.line_items.of_interest render locals: {order: order, line_items: line_items} end end

Slide 17

Slide 17 text

N+1 queries def line_items_for_recent_orders orders = Order.limit(5) orders.flat_map do |order| order.line_items.group_by(&:name) end end Order Load (0.6ms) SELECT "orders".* FROM "orders" LIMIT $1 [["LIMIT", 5]] LineItem Load (0.3ms) SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" = $1 [["order_id", "abc"]] LineItem Load (0.2ms) SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" = $1 [["order_id", "def"]] LineItem Load (0.3ms) SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" = $1 [["order_id", "ghi"]] LineItem Load (0.1ms) SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" = $1 [["order_id", "jkl"]] LineItem Load (0.1ms) SELECT "line_items".* FROM "line_items" WHERE "line_items"."order_id" = $1 [["order_id", “mno"]]

Slide 18

Slide 18 text

Solution: eager loading def line_items_for_recent_orders orders = Order.includes(:line_items).limit(5) orders.flat_map do |order| order.line_items.group_by(&:name) end end Order Load (0.3ms) SELECT "orders".* FROM "orders" /* loading for pp */ LIMIT $1 [["LIMIT", 5]] LineItem Load (0.6ms) SELECT "line_items".* FROM "line_items" WHERE “line_items"."order_id" IN ($1, $2, $3, $4, $5) [["order_id", “c924e315-f9d2-482e-9bf2-e230b77a5215"], ["order_id", “47576b40-916e-4a54-b4e7-e119ea81ed44"], ["order_id", “94a6a533-e64e-4d92-b1dd-b5cd6a0a096c"], ["order_id", “fc5062d4-38f3-40fd-add3-e530435b22e0"], ["order_id", "8e96d3b9-0daa-42b2-bfc9-cc9703cd2eca"]]

Slide 19

Slide 19 text

Solution: eager loading def line_items_for_recent_orders orders = Order.includes(:line_items).limit(5) orders.flat_map do |order| order.line_items.group_by(&:name) end end Order Load (0.3ms) SELECT "orders".* FROM "orders" /* loading for pp */ LIMIT $1 [["LIMIT", 5]] LineItem Load (0.6ms) SELECT "line_items".* FROM "line_items" WHERE “line_items"."order_id" IN ($1, $2, $3, $4, $5) [["order_id", “c924e315-f9d2-482e-9bf2-e230b77a5215"], ["order_id", “47576b40-916e-4a54-b4e7-e119ea81ed44"], ["order_id", “94a6a533-e64e-4d92-b1dd-b5cd6a0a096c"], ["order_id", “fc5062d4-38f3-40fd-add3-e530435b22e0"], ["order_id", "8e96d3b9-0daa-42b2-bfc9-cc9703cd2eca"]]

Slide 20

Slide 20 text

1. `bullet` gem 2. Rails built-in strict loading mode 3. `rack-mini-profiler` and the `stackprof`gems 4. Monitor with SaaS tools 5. Manually review your application’s log files and look for repeated queries that are being executed. How to identify N+1 queries in a Rails app? Q: Given how easy it is in Active Record to make complex queries, how do you protect against n+1?

Slide 21

Slide 21 text

Knowing too much about your neighbours' neighbours class Address < ApplicationRecord belongs_to :user end class User < ApplicationRecord has_many :addresses has_many :orders def billing_address addresses.billing.first end end class Order < ApplicationRecord belongs_to( :customer, class_name: "User", foreign_key: :user_id, optional: false ) end

Slide 22

Slide 22 text

Knowing too much about your neighbours' neighbours # in our view <%= @order.customer.name %> <%= @order.customer.billing_address.street %> <%= @order.customer.billing_address.city %> <%= @order.customer.billing_address.state %> <%= @order.customer.billing_address.postcode %>

Slide 23

Slide 23 text

Knowing too much about your neighbours' neighbours # in our view <%= @order.customer.name %> <%= @order.customer.billing_address.street %> <%= @order.customer.billing_address.city %> <%= @order.customer.billing_address.state %> <%= @order.customer.billing_address.postcode %> Law of Demeter @order.try(:customer) .try(:billing_address) .try(:street)

Slide 24

Slide 24 text

Solution 1: delegate # app/models/user.rb class User < ApplicationRecord has_many :addresses has_many :orders delegate( :street, :city, :state, :postcode, to: :billing_address, prefix: :billing ) end # app/models/order.rb class Order < ApplicationRecord belongs_to( :customer, class_name: "User", foreign_key: :user_id, optional: false ) delegate(:name, to: :customer, prefix: true) delegate( :billing_street, :billing_city, :billing_state, :billing_postcode, to: :customer ) end

Slide 25

Slide 25 text

Solution 1: delegate # in our view <%= @order.customer_name %> <%= @order.billing_street %> <%= @order.billing_city %> <%= @order.billing_state %> <%= @order.billing_postcode %> Q: why is this better?

Slide 26

Slide 26 text

Solution 2: a presenter with a SimpleDelegator # app/presenters/order_presenter.rb class OrderPresenter < SimpleDelegator def initialize(order) super(order) end def customer_name customer.name end def billing_street customer.street end #... end

Slide 27

Slide 27 text

Solution 2: a presenter with a SimpleDelegator # app/presenters/order_presenter.rb class OrderPresenter < SimpleDelegator def initialize(order) super(order) end def customer_name customer.name end def billing_street customer.street end #... end # in our view <%= @order.customer_name %> <%= @order.billing_street %> <%= @order.billing_city %> <%= @order.billing_state %> <%= @order.billing_postcode %>

Slide 28

Slide 28 text

Dealing with nils undefined method `street' for nil:NilClass @order.try(:customer) .try(:billing_address) .try(:street)

Slide 29

Slide 29 text

Dealing with nils # app/views/orders/show.html.erb <%- if order.customer.billing_address.present? %>

Customer billing address: ... ↙p> <%- else %>

No address↙p> <%- end %>

Slide 30

Slide 30 text

Null object pattern # app/models/null_address.rb class NullAddress def street "No street" end def city "No city" end def state "No state" end def postcode "0000" end end

Slide 31

Slide 31 text

Null object pattern # app/models/null_address.rb class NullAddress def street "No street" end def city "No city" end def state "No state" end def postcode "0000" end end class User < ApplicationRecord def billing_address addresses.billing.first || NullAddress.new end end

Slide 32

Slide 32 text

Null object pattern

Welcome, <% if current_user %> <%= current_user.name %> <% else %> Guest <% end %> ↙p>

Slide 33

Slide 33 text

Null object pattern # app/controllers/application_controller.rb require "ostruct" def current_user @current_user ||= User.find session[:user_id] || OpenStruct.new(name: "Guest") end helper_method :current_user

Slide 34

Slide 34 text

Null object pattern # back in the view

Welcome, <%= current_user.name %> ↙p> # app/controllers/application_controller.rb require "ostruct" def current_user @current_user ||= User.find session[:user_id] || OpenStruct.new(name: "Guest") end helper_method :current_user

Slide 35

Slide 35 text

Null object pattern # app/models/guest.rb class Guest def name "Guest" end end # app/controllers/application_controller.rb def current_user @current_user ||= User.find session[:user_id] || Guest.new end helper_method :current_user

Slide 36

Slide 36 text

1. Domain logic 2. Even just presentational logic can become complex 3. Difficult to maintain 4. Difficult to test 5. Not DRY ISSUES Overloading views with logic

Slide 37

Slide 37 text

1. Domain logic 2. Even just presentational logic can become complex 3. Difficult to maintain 4. Difficult to test 5. Not DRY ISSUES SOLUTIONS Overloading views with logic 1. Custom view helpers 2. Presenters, decorators, view models 3. Template inheritance and nested layouts

Slide 38

Slide 38 text

Another presenter example # app/presenters/customer_presenter.rb class CustomerPresenter < SimpleDelegator include ActionView::Helpers::NumberHelper def initialize(customer) super(customer) end def location AddressPresenter.new(customer.billing_address).location end def recent_order_history # do something with recent orders end def phone_number number_to_phone(phone_number, delimiter: "-") end end

Slide 39

Slide 39 text

Local variables, strict locals, and magic comments # app/views/orders/show.html.erb <%= render partial: "orders/order", locals: { order: @order } %> # app/views/orders/_order.html.erb <%= tag.div id: dom_id(order) do %>

<%= order.name %>↙h1> <% end %>

Slide 40

Slide 40 text

Local variables, strict locals, and magic comments # app/views/orders/show.html.erb <%= render "orders/order", order: @order %> # app/views/orders/_order.html.erb <%= tag.div id: dom_id(order) do %>

<%= order.name %>↙h1> <% end %>

Slide 41

Slide 41 text

Local variables, strict locals, and magic comments # app/views/orders/show.html.erb <%= render "orders/order", order: @order %> local_assigns[:order]

Slide 42

Slide 42 text

Local variables, strict locals, and magic comments # app/views/orders/show.html.erb <%# locals: (order:) -%> <%= render "orders/order" %> # => ActionView::Template::Error: missing local: :order for app/views/orders/_order.html.erb

Slide 43

Slide 43 text

Fat models and God objects 1. Violates SOLID 2. Fragile code 3. Harder to test 4. Damages performance 5. Harder to understand

Slide 44

Slide 44 text

Fat models and god objects # app/models/order.rb class Order < ApplicationRecord def to_xml; ... end def to_json; ... end def to_csv; ... end def to_pdf; ... end # does many more things... end

Slide 45

Slide 45 text

Solution: delegate responsibility to new classes # app/models/order_converter.rb class OrderConverter attr_reader :order def initialize(order) @order = order end def to_xml; ... end def to_json; ... end def to_csv; ... end def to_pdf; ... end end

Slide 46

Slide 46 text

Delegate responsibility to new classes # app/models/order_converter.rb class OrderConverter attr_reader :order def initialize(order) @order = order end def to_xml; ... end def to_json; ... end def to_csv; ... end def to_pdf; ... end end # app/models/order.rb class Order < ApplicationRecord delegate( :to_xml, :to_json, :to_csv, :to_pdf, to: :converter ) def converter OrderConverter.new(self) end end

Slide 47

Slide 47 text

Callbacks # http://api.rubyonrails.org/classes/ActiveRecord/Callbacks.html CALLBACKS = [ :after_initialize, :after_find, :after_touch, :before_validation, :after_validation, :before_save, :around_save, :after_save, :before_create, :around_create, :after_create, :before_update, :around_update, :after_update, :before_destroy, :around_destroy, :after_destroy, :after_commit, :after_rollback ] > Callbacks are hooks into the life cycle of an Active Record object that allow you to trigger logic before or after an alteration of the object state

Slide 48

Slide 48 text

Callbacks class Order < ApplicationRecord belongs_to :user has_many :line_items after_create :purchase_completion_notification def purchase_completion_notification OrderMailer.purchase_notifier(self).deliver end end # in a spec allow_any_instance_of(Order). to receive(:purchase_completion_notification). and_return(true)

Slide 49

Slide 49 text

Solution: use observers or service objects # app/services/complete_order.rb class CompleteOrder attr_accessor :order def initialize(order_params) @order = Order.new(order_params) end def self.call(order) new(order).call end def call if order.save OrderMailer.purchase_notifier(order).deliver end order end end

Slide 50

Slide 50 text

Solution: use observers or service objects # app/services/complete_order.rb class CompleteOrder attr_accessor :order def initialize(order_params) @order = Order.new(order_params) end def self.call(order) new(order).call end def call if order.save OrderMailer.purchase_notifier(order).deliver end order end end # app/controllers/orders_controller.rb def create @order = CompleteOrder.call(params[:order]) respond_with @order end

Slide 51

Slide 51 text

Separation of concerns — single responsibility # app/models/user.rb class User < ActiveRecord::Base include UserConcerns::Constants include UserConcerns::PersonalDetails include UserConcerns::Registration include UserConcerns::Pulse include UserConcerns::Enrolment include UserConcerns::Sme include Shared::DeleteSoftly Q: when breaking apart a class that has too many responsibilities, what are the pros and cons of a PORO with delegation vs a concern? Q: If you delegate, do you test that the delegation exists or only test the class that is delegated to?

Slide 52

Slide 52 text

1. Move tasks to background jobs 2. Set timeouts Sluggish services — blocking on calls to external services

Slide 53

Slide 53 text

Sluggish services — blocking on calls to external services # change the timeout from the default of 60 secs down to 3 uri = URI("http://example.com") request = Net::HTTP::Post.new(uri.path) http = Net::HTTP.new(uri.host, uri.port).start http.read_timeout = 3 response = http.request(request) # Faraday conn.get do |req| req.url "/search" req.options.timeout = 5 # open/read timeout in seconds req.options.open_timeout = 2 # connection open timeout in secs end # Http.rb HTTP. timeout(:per_operation, write: 2, connect: 5, read: 10). get("http://example.com")

Slide 54

Slide 54 text

Failure to respond def create @order = Order.new(params[:order]) respond_to do |format| if @order.save format.xml { render xml: @order } else format.xml { render xml: @order.errors } end end end

Slide 55

Slide 55 text

Solution: return explicit HTTP status codes def create @order = Order.new(params[:order]) respond_to do |format| if @order.save format.xml { render xml: @order, status: :created, } else format.xml { render xml: @order.errors, status: :unprocessable_entity, } end end end

Slide 56

Slide 56 text

Controllers Views Models Requests In summary SRP! Testing & Refactoring [email protected] blackmill.co

Slide 57

Slide 57 text

Thanks https://blackmill.co/coaching-training/ workshops/tdd-with-rspec Test Driven Development with RSpec Elle Meredith [email protected] blackmill.co build an effective and fast testing environment 30 July – 2 August