Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building Maintainable Rails Apps

Building Maintainable Rails Apps

This talk was given at RubyC in Kiev in June 2016.

The video for this talk is here: http://andypike.com/blog/conferences/rubyc-2016/

We love Rails! When the project is young we move at light speed! As time moves on, code entangles and development slows. In this talk we will explore why this happens and ways you can avoid it. You will learn a different “Rails Way” that retains what we love and adds good object orientated design which makes your app easier to maintain. We will see how to implement form objects, service objects and presenters in a Rails app to separate responsibilities and aid maintainability. If your Rails app is hard to maintain or you would like to learn techniques to avoid it, this talk is for you.

Andy Pike

June 04, 2016
Tweet

More Decks by Andy Pike

Other Decks in Programming

Transcript

  1. I’m a freelance developer You can hire me I can

    totally 100% help you You can hire me!
  2. ∞ Light speed Fast Medium Slow Age of project Time

    to develop a feature Heat death of the universe
  3. class UsersController < ApplicationController def new @user = User.new end

    def create @user = User.new(user_params) if @user.save redirect_to dashboard_path else render :new end end private def user_params params.require(:user).permit(:first_name, :last_name, :email) end end
  4. def update @user = User.find(params[:id]) if @user.update(update_user_params) redirect_to dashboard_path else

    render :edit end end private def create_user_params params.require(:user).permit(:first_name, :last_name, :email) end def update_user_params params.require(:user).permit(:first_name, :last_name, :email, :age) end
  5. class User < ActiveRecord::Base has_many :things validates :email, :presence =>

    true validates :age, :presence => true, :if => :persisted? end
  6. def update @user = User.find(params[:id]) user_params = current_user.admin? ? admin_user_params

    : update_user_params if @user.update(user_params) redirect_to dashboard_path else render :edit end end private # def create_user_params # def update_user_params def admin_user_params params.require(:user).permit(:first_name, :last_name, :email, :age, :role) end
  7. = simple_form_for @company do |f| = f.input :name = f.simple_fields_for

    :users_attributes, @user do |u| = u.input :full_name = u.input :email = f.submit "Sign Up"
  8. class Company < ActiveRecord::Base has_many :users accepts_nested_attributes_for :users end class

    User < ActiveRecord::Base belongs_to :company def full_name [first_name, last_name].join(" ") end def full_name=(name) names = name.split(" ", 2) self.first_name = names.first self.last_name = names.last end end
  9. { "utf8" => "✓", "authenticity_token" => "gjsQSQXchsBI7yuz…", "user" => {

    "full_name" => "Andy Pike", "age" => "25" }, "commit" => "Save", "id" => "1" } 2016 - params[:user][:age] # => TypeError: String can't be coerced into Fixnum
  10. class RegisterUserForm < Rectify::Form attribute :first_name, String attribute :last_name, String

    attribute :email, String validates :email, :presence => true end class UpdateProfileForm < Rectify::Form attribute :first_name, String attribute :last_name, String attribute :email, String attribute :age, Integer validates :email, :presence => true validates :age, :presence => true end
  11. class RegisterForm < Rectify::Form attribute :company_name, String attribute :full_name, String

    attribute :email, String validates :email, :presence => true def first_name name_parts.first end def last_name name_parts.last end private def name_parts full_name.split(" ", 2) end end
  12. = simple_form_for @company do |f| = f.input :name = f.simple_fields_for

    :users_attributes, @user do |u| = u.input :full_name = u.input :email = f.submit "Sign Up" # vs = simple_form_for @form do |f| = f.input :company_name = f.input :full_name = f.input :email = f.submit "Sign Up"
  13. def new @form = RegisterUserForm.new end def create @form =

    RegisterUserForm.from_params(params) if @form.valid? # Do something interesting else render :new end end # no strong_parameters :o)
  14. def edit user = User.find(params[:id]) @form = UpdateProfileForm.from_model(user) end def

    update @form = UpdateProfileForm.from_params(params) # @form.first_name => "Andy Pike" # @form.age => 25 # @form.attributes => { :first_name => "Andy Pike" ... } if @form.valid? # Do something interesting else render :edit end end
  15. class AccountHolderForm < UserForm attribute :age, Integer attribute :colours, Array

    attribute :address, AddressForm attribute :contacts, Array[ContactForm] end @form = AccountHolderForm.from_params(params) @form.valid? # validates the form, superclass, # nested forms and array of forms
  16. UserMailer.confirmation(@user).deliver_later case @user.potential_revenue when :low logger.info("We don’t care about #{@user.full_name}")

    when :medium CRM.add(@user) when :high CRM.add(@user) SMS.notify_sales_high_value_customer(@user) end redirect_to dashboard_path else render :new end end ȴ def create @user = User.new(user_params) if @user.save
  17. class User def register if save UserMailer.confirmation(self).deliver_later case potential_revenue when

    :low logger.info("We don’t care about #{full_name}") when :medium CRM.add(self) when :high CRM.add(self) SMS.notify_sales_high_value_customer(self) end end persisted? end end ȴ
  18. class User def register return false unless save send_confirmation send("#{potential_revenue}_potential_revenue")

    true end def send_confirmation UserMailer.confirmation(self).deliver_later end def low_potential_revenue def medium_potential_revenue def high_potential_revenue end ȴ
  19. ȴ

  20. ȴ

  21. Encapsulate a business task Named using Verbs Called by the

    Controller Multiple results No callbacks ȴ
  22. class RegisterUser < Rectify::Command def initialize(form) @form = form end

    def call return broadcast(:invalid) if @form.invalid? # Your code goes here broadcast(:ok) end private # def do_something # end end ȴ
  23. class PlaceOrder < Rectify::Command def initialize(form, customer) @form = form

    @customer = customer end def call return broadcast(:invalid) if @form.invalid? return broadcast(:card_expired) if card_expired? transaction do order = create_order update_crm notify_customer broadcast(:ok, order) end end private def create_order ȴ
  24. def create @form = OrderForm.from_params(params) PlaceOrder.call(@form, current_user) do on(:ok) {

    |order| redirect_to order_path(order) } on(:invalid) { render :new } on(:card_expired) { redirect_to billing_path } end end ȴ
  25. ȴ

  26. class UsersController < ApplicationController def index @users = User.where(:plays_minecraft =>

    true) end def show @user = User.find(params[:id]) @recent_orders = @user.orders.where("created_at > ?", 5.days.ago) end end 
  27. class User < ActiveRecord::Base has_many :orders scope :cool, -> {

    where(:plays_minecraft => true) } def recent_orders orders.where("created_at > ?", 5.days.ago) end end 
  28. def self.programmer_admins important_value = some_logic_required_by_the_query active .admin .programmer .where("SOME SQL

    ?", important_value) .where("SOME MORE REALLY LONG COMPLEX SQL") .where("SOME MORE REALLY REALLY LONG COMPLEX SQL") .order(:created_at => :desc) end def self.some_logic_required_by_the_query # Involved calculations end 
  29. def self.important_orders sql = <<-SQL.strip_heredoc WITH regional_sales AS ( SELECT

    region, SUM(amount) AS total_sales FROM orders GROUP BY region ), top_regions AS ( SELECT region FROM regional_sales WHERE total_sales > (SELECT SUM(total_sales)/10 FROM regional_sales) ) SELECT region, product, SUM(quantity) AS product_units, SUM(amount) AS product_sales FROM orders WHERE region IN (SELECT region FROM top_regions) AND amount > :amount GROUP BY region, product; SQL Order.find_by_sql([sql, { :amount => 50 }]) end 
  30. class CoolUsers < Rectify::Query def query User.where(:plays_minecraft => true) end

    end # Usage: CoolUsers.new.count CoolUsers.new.first CoolUsers.new.exists? CoolUsers.new.none? CoolUsers.new.to_a CoolUsers.new.map(&:age) # All Enumerable methods CoolUsers.new.each do |user| puts user.name end 
  31. class UsersOlderThan < Rectify::Query def initialize(age) @age = age end

    def query User.where("age > ?", @age) end end UsersOlderThan.new(25).count 
  32. class UsersWithBlacklistedEmail < Rectify::Query def initialize(blacklist) @blacklist = blacklist end

    def query User.where(:email => blacklisted_emails) end private def blacklisted_emails @blacklist.map { |b| b.email.strip.downcase } end end 
  33. class UsersOverUsingSql < Rectify::Query include Rectify::SqlQuery def initialize(age) @age =

    age end def model User end def sql <<-SQL.strip_heredoc SELECT * FROM users WHERE age > :age ORDER BY age ASC SQL end def params { :age => @age } end end 
  34. module ApplicationHelper def hours 8.upto(22) do |hour| time = "#{hour.to_s.rjust(2,

    '0')}:00" content_tag(:div, time, :class => "timestamp") end end end <%= hours %>
  35. module ApplicationHelper def billing_link return unless current_user.admin? && current_user.company.plan.paid? link_to

    "#{current_user.company.name} Billing", account_path end end <div> <%= billing_link %> </div>
  36. class CalendarPresenter < Rectify::Presenter def hours 8.upto(22) do |hour| yield

    formatted_time(hour) end end private def formatted_time(hour) "#{hour.to_s.rjust(2, '0')}:00" end end
  37. class CalendarController < ApplicationController include Rectify::ControllerHelpers def show present CalendarPresenter.new

    end end <% presenter.hours do |hour| %> <div class="timestamp"><%= hour %></div> <% end %>
  38. class BillingPresenter < Rectify::Presenter attribute :user, User def billing_link return

    unless show_billing? link_to "#{user.company.name} Billing", account_path end private def show_billing? user.admin? && user.company.plan.paid? end end
  39. def show present BillingPresenter.new(:user => current_user) present MenuPresenter.new, :for =>

    :menu present FooterPresenter.new, :for => :footer end <nav><%= presenter(:menu).login_link %></nav> <div><%= presenter.billing_link %></div> <footer> <%= presenter(:footer).copyright_notice %> </footer>
  40. Testing Forms RSpec.describe UserForm do subject { described_class.new(:name => "Andy")

    } it "is valid with valid attributes" do expect(subject).to be_valid end describe "#name" do it "cannot be blank" do subject.name = "" expect(subject).to be_invalid end end end
  41. Stubbing Forms form = stub_form(:valid? => true, :name => "Andy")

    form.valid? # => true form.invalid? # => false form.name # => "Andy" form.attributes # => { :name => "Andy" } # or form = Rectify::StubForm.new(:valid? => true, :name => “Andy")
  42. Testing Commands ȴ RSpec.describe PlaceOrder do context "when the form

    is valid" do it "broadcasts :ok" do form = stub_form(:valid? => true) command = described_class.new(form) expect { command.call }.to broadcast(:ok) end end end
  43. Testing Queries  RSpec.describe UsersOlderThan do it "returns users older

    than the supplied age" do older = create(:user, :age => 10) younger = create(:user, :age => 8) expect(UsersOlderThan.new(9).to_a).to match_array([older]) end end
  44. Stubbing Queries  it "returns the only user's age" do

    stub_query(AllUsers, :results => User.new(:age => 25)) expect(subject.total_ages).to eq(25) end it "returns the sum of all users ages" do stub_query(AllUsers, :results => [ User.new(:age => 25), User.new(:age => 20) ]) expect(subject.total_ages).to eq(45) end
  45. Testing Presenters it "renders the billing link" do presenter =

    BillingPresenter.new(:user => admin_user) expect(presenter.billing_link).to eq('<a href="/billing">Billing</a>') end it "renders the billing link for current_user” do presenter = BillingPresenter.new presenter.attach_controller(ApplicationController) # use current_user expect(presenter.billing_link).to eq('<a href="/billing">Billing</a>') end
  46. pragmatic /praɡˈmatɪk/ adjective dealing with things sensibly and realistically in

    a way that is based on practical rather than theoretical considerations.
  47. ∞ Light speed Fast Medium Slow Age of project Time

    to develop a feature Interesting point
  48. Tweet me questions or catch up with me later Don’t

    forget, you can hire me! @andypike