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

Rails, Objects, and Long Lived Code

Rails, Objects, and Long Lived Code

Ancient City Ruby 2015

Noel Rappin

March 25, 2015
Tweet

More Decks by Noel Rappin

Other Decks in Technology

Transcript

  1. Instructions: 4 Please create a name tag 4 Go to

    https://github.com/noelrappin/ancient_city 4 Follow the instructions 4 Wifi Name: PrivateGroup 4 Wifi password: CMHgroup15
  2. A Plan 4 I'm gonna talk for a few minutes

    4 We're going to look at code together 4 We're going to make changes together 4 You're going to make changes on your own
  3. A brief, idiosyncratic history 4 Dec 2005: Rails 1.0 Released

    4 Oct 2006: Jamis Buck writes "Skinny Controller, Fat Model" 4 Dec 2007: Rails 2.0 Released (REST) 4 Mar 2010: James Golick writes "Crazy, Heretical, and Awesome" 4 Aug 2010: Rails 3.0 Released
  4. A brief, idiosyncratic history 4 Sep 2011: Cory Haines talk

    "Fast Rails Tests" 4 Nov 2011: Uncle Bob talk on "Architecture: The Lost Years" 4 Jan 2012: Avdi Grimm writes "Objects on Rails" 4 April 2012: Rails API project starts 4 Mar 2012: Jim Gay writes "Clean Ruby" 4 July 2012: Matt Wynne talk "Hexagonal Rails"
  5. A brief, idiosyncratic history 4 Oct 2012: Brian Helmcamp "7

    Patterns to Refactor" 4 June 2013: Rails 4.0 Released 4 Aug 2013: Ben Smith "How I architected" 4 Oct 2013: Jim Weirich on Decoupling from Rails 4 Apr 2014: DHH RailsConf keynote 4 Nov 2014: Trailblazer by Nick Sutterer
  6. Where are we now? A lot of apps with the

    same problems: 4 My views are too complicated 4 My tests are too slow 4 My models are too big 4 Everything is too tangled
  7. Where we are now? A lot of people have answers

    4 Service Oriented Architecture 4 Presenters 4 Data, Context, And Interaction (DCI) 4 Hexagonal Rails 4 Trailblazer 4 Engines
  8. Where we are now? A lot of people have answers

    4 Dependency Injection 4 Single Responsibility Principle 4 Separation of concerns
  9. "All problems in computer science can be solved by another

    level of indirection" 1 David Wheeler
  10. Can we build complex apps without them turning into: A

    ball of mud An overcomplicated mess
  11. All these techniques seem like crazy overkill until you realize

    you should have done them six months ago
  12. But DHH has less to fear than you do of

    Rails changing under him.
  13. What's wrong with this? index.html.haml %h1.trip_index_header#headline Time Travel Adventures -

    @trips.each_slice(3) do |group| .row - group.each do |trip| .col-md-3.trip_entry[trip] .trip_header= link_to trip.name, trip .trip_tag= trip.tag_line .trip_dates= "#{trip.start_date.to_s(:long)} - #{trip.end_date.to_s(:long)}" .trip_image= image_tag(trip.image_name, :height => 150, :width => 150) .trip_price= number_to_currency(trip.price) .trip_links = link_to "Show Details", trip, :class => 'detail_toggle' .detail_hidden %div= trip.description
  14. Split the view %h1.trip_index_header#headline Time Travel Adventures - @trips.each_slice(3) do

    |group| .row= render partial: "one_trip", collection: group, as: :trip
  15. .col-md-3.trip_entry[trip] .trip_header= link_to trip.name, trip .trip_tag= trip.tag_line .trip_dates= "#{trip.start_date.to_s(:long)} -

    #{trip.end_date.to_s(:long)}" .trip_image= image_tag(trip.image_name, :height => 150, :width => 150) .trip_price= number_to_currency(trip.price) .trip_links = link_to "Show Details", trip, :class => 'detail_toggle' .detail_hidden %div= trip.description
  16. We could do this: class Trip < ActiveRecord::Base has_many :hotels

    has_many :activities validates_presence_of :description, :start_date, :end_date, :price, :tag_line def date_range_display "#{start_date.to_s(:long)} - #{end_date.to_s(:long)}" end end
  17. Which is bad because... 4 Our chocolate is in our

    peanut butter 4 They will not taste great together
  18. Let's try this: class TripPresenter < SimpleDelegator def initialize(trip) super(trip)

    end def date_range_display "#{start_date.to_s(:long)} - #{end_date.to_s(:long)}" end end
  19. A little more support class HomeController < ApplicationController def index

    @trips = Trip.all.map { |t| TripPresenter.new(t) } end end
  20. And then... .span3.trip_entry[trip.__getobj__] .trip_header= trip.link_to_trip_show_page .trip_tag= trip.tag_line .trip_dates= trip.date_range_display .trip_image=

    trip.main_page_image_tag .trip_price= trip.price_display .trip_links = link_to "Show Details", trip, :class => 'detail_toggle' .detail_hidden %div= trip.description
  21. And... class TripPresenter < SimpleDelegator include ActionView::Helpers::UrlHelper include ActionView::Helpers::AssetTagHelper include

    ActionView::Helpers::NumberHelper def initialize(trip) super(trip) end def date_range_display "#{start_date.to_s(:long)} - #{end_date.to_s(:long)}" end def link_to_trip_show_page link_to(name, Rails.application.routes.url_helpers.trip_path(__getobj__)) end def main_page_image_tag image_tag("/assets/#{image_name}", height: 150, width: 150) end def price_display number_to_currency(price) end end
  22. Why is this better? 4 View code declares intent 4

    Able to more easily unit test presentation logic 4 Easier to reason about presentation code 4 Potential for reuse
  23. %h1= @trip.name %h2 Booking Options = form_for Order.new do |f|

    .options %h3 Hotel Options .length_of_stay Number of nights = hidden_field_tag :trip_id, @trip.id = select_tag :length_of_stay, options_for_select((1 .. (@trip.end_date - @trip.start_date)).to_a), include_blank: true - @trip.hotels.each do |hotel| %div[hotel] = radio_button_tag(:hotel_id, hotel.id, false) = "#{hotel.name}: #{hotel.description} (#{number_to_currency(hotel.price)})" %h3 Activity Options - @trip.activities.each do |activity| %div[activity] = check_box_tag(:"activity_id[]", activity.id, false, id: "activity_id_#{activity.id}") = "#{activity.name}: #{activity.description} (#{number_to_currency(activity.price)})" %br = f.submit "Order"
  24. An integration test describe "basic process" do it "creates order

    and line item objects" do visit("/trips/#{mayflower.id}") select('4', :from => 'length_of_stay') choose("hotel_id_#{mayflower.hotels.first.id}") check("activity_id_#{mayflower.activities.first.id}") click_button("Order") order = Order.last expect(order.order_line_items.count).to eq(3) expect(order.order_line_items.map(&:buyable)).to eq( [mayflower, mayflower.hotels.first, mayflower.activities.first]) end end
  25. Passing class OrdersController < ApplicationController def create order = Order.new

    order.order_line_items.new(buyable: Trip.find(params[:trip_id])) order.order_line_items.new(buyable: Hotel.find(params[:hotel_id])) params[:activity_id].each do |aid| order.order_line_items.new(buyable: Activity.find(aid)) end order.save redirect_to :root end end
  26. Let's try again class OrdersController < ApplicationController def create action

    = PurchasesOrder.new( params[:trip_id], params[:hotel_id], params[:activity_id]) action.run redirect_to :root end end
  27. And the action: class PurchasesOrder def initialize(trip_id, hotel_id, activity_ids) @trip_id,

    @hotel_id, @activity_ids = trip_id, hotel_id, activity_ids end def trip @trip ||= Trip.find(@trip_id) end def hotel @hotel ||= Hotel.find(@trip_id) end def activities @activities ||= @activity_ids.map { |id| Activity.find(id) } end def order @order ||= Order.new end def add_line_item(buyable) order.order_line_items.new(buyable: buyable) end def run add_line_item(trip) add_line_item(hotel) activities.each { |a| add_line_item(a) } order.save end end
  28. Why is this better? 4 Unit testable 4 Better able

    to manage future complexity 4 Declares intent
  29. Algorithm The sum of: 4 Activities have a price 4

    Hotels have a nights stayed times a price 4 Trips have a price
  30. 4 Logic in existing models 4 Logic in workflow object

    4 Create new calculator object 4 Some combination thereof
  31. Starting with an acceptance test preserves ambiguity it "correctly puts

    pricing in the line item objects" do visit("/trips/#{mayflower.id}") select('4', :from => 'length_of_stay') choose("hotel_id_#{mayflower.hotels.first.id}") check("activity_id_#{mayflower.activities.first.id}") click_button("Order") order = Order.last expect(order.trip_item.unit_price).to eq(1200) expect(order.trip_item.amount).to eq(1) expect(order.trip_item.total_price).to eq(1200) expect(order.hotel_item.unit_price).to eq(500) expect(order.hotel_item.amount).to eq(4) expect(order.hotel_item.total_price).to eq(2000) expect(order.activity_items.first.unit_price).to eq(400) expect(order.activity_items.first.amount).to eq(1) expect(order.activity_items.first.total_price).to eq(400) expect(order.total_price_paid).to eq(3600) end
  32. Get to Green class Order < ActiveRecord::Base has_many :order_line_items def

    trip_item order_line_items.where(buyable_type: "Trip").first end def hotel_item order_line_items.where(buyable_type: "Hotel").first end def activity_items order_line_items.where(buyable_type: "Activity") end end
  33. Get To Green class OrdersController < ApplicationController def create PurchasesOrder.new(

    params[:trip_id], params[:hotel_id], params[:activity_id], params[:length_of_stay]).run redirect_to :root end end
  34. Get To Green def add_line_item(buyable, unit_price, amount) order.order_line_items.new(buyable: buyable, unit_price:

    unit_price, amount: amount, total_price: amount * unit_price) end def run add_line_item(trip, trip.price, 1) add_line_item(hotel, hotel.price, length_of_stay.to_i) activities.each { |a| add_line_item(a, a.price, 1) } order.total_price_paid = order.order_line_items.map(&:total_price).sum order.save end
  35. Let's make this more complicated Processing Fee: 4 $10 per

    trip 4 $1 per 100 years the trip start date is from 2015 4 $5 per activity 4 $10 if hotel per night fee is over $250
  36. Why not put this in the model? 4 Most of

    the time, the model doesn't care 4 SRP
  37. Let's build this bottom up require 'rails_helper' describe ActivityProcessingFee do

    it "always returns $5" do calculator = ActivityProcessingFeeCalculator.new(double) expect(calculator.fee).to eq(Money.new(500)) end end
  38. Then require 'rails_helper' describe HotelProcessingFee do it "returns zero for

    a hotel under $250" do hotel = double(price: 100) calculator = HotelProcessingFeeCalculator.new(hotel) expect(calculator.fee).to eq(Money.zero) end it "returns $10 for a hotel that is greater than $250" do hotel = double(price: 300) calculator = HotelProcessingFeeCalculator.new(hotel) expect(calculator.fee).to eq(Money.new(1000)) end end
  39. And... require 'rails_helper' describe TripProcessingFee do it "returns expected trip

    that is within 100 years" do trip = double(start_date: Date.parse("Jan 1, 2000")) calculator = TripProcessingFee.new(trip) expect(calculator.fee).to eq(Money.new(15)) end end
  40. Passing class HotelProcessingFee attr_accessor :hotel def initialize(hotel) @hotel = hotel

    end def fee if hotel.price > 250 then Money.new(1000) else Money.zero end end end
  41. More passing class TripProcessingFee attr_accessor :trip def initialize(trip) @trip =

    trip end def year trip.start_date.year end def fee Money.new(Time.now.year - year) end end
  42. Acceptance it "correctly puts pricing in the activity line item

    objects" do order = Order.last activity = order.activity_items.first expect(activity.unit_price).to eq(400) expect(activity.amount).to eq(1) expect(activity.extended_price).to eq(400) expect(activity.processing_fee).to eq(5) end it "correctly puts pricing in the order object" do order = Order.last expect(order.total_price_paid).to eq(3600 + 3.95 + 10 + 5 + 10) end
  43. Passing def add_line_item(buyable, unit_price, amount, calculator_class) extended_price = amount *

    unit_price processing_fee = calculator_class.new(buyable).fee order.order_line_items.new(buyable: buyable, unit_price: unit_price, amount: amount, extended_price: extended_price, processing_fee: processing_fee, price_paid: extended_price + processing_fee) end def calculate_order_price order.order_line_items.map(&:price_paid).sum + Money.new(1000) end def run add_line_item(trip, trip.price, 1, TripProcessingFee) add_line_item( hotel, hotel.price, length_of_stay.to_i, HotelProcessingFee) activities.each do |a| add_line_item(a, a.price, 1, ActivityProcessingFee) end order.total_price_paid = calculate_order_price order.save end
  44. Requirements 4 A code can offer a percentage discount 4

    A code can apply to trips, hotels, activities, or all 3 4 A code does not apply to processing costs 4 Free items don't have processing costs
  45. class OrderLineItemFactory attr_accessor :order, :buyable, :unit_price, :amount, :fee_calculator_class def initialize(order,

    buyable, unit_price, amount, fee_calculator_class) @order, @buyable, @unit_price, @amount = order, buyable, unit_price, amount @fee_calculator_class = fee_calculator_class end def extended_price amount * unit_price end def processing_fee fee_calculator_class.new(buyable).fee.to_f end def price_paid extended_price + processing_fee end def run order.order_line_items.new(buyable: buyable, unit_price: unit_price, amount: amount, extended_price: extended_price, processing_fee: processing_fee, price_paid: price_paid) end end
  46. Passsing code through %h3 Coupon Code = text_field_tag :coupon_code def

    create PurchasesOrder.new( params[:trip_id], params[:hotel_id], params[:activity_id], params[:length_of_stay], params[:coupon_code]).run redirect_to :root end def initialize(trip_id, hotel_id, activity_ids, length_of_stay, code) @trip_id, @hotel_id, @activity_ids = trip_id, hotel_id, activity_ids @length_of_stay = length_of_stay @code = code end def coupon_code @coupon_code ||= CouponCode.find_by_code(@code) end def add_line_item(buyable, unit_price, amount, calculator_class) OrderLineItemFactory.new(order, buyable, unit_price, amount, coupon_code, calculator_class).run end
  47. Passing logic def price_paid extended_price - discount + processing_fee end

    def discount return 0 unless coupon_code (coupon_code.discount_percentage * extended_price) / 100.0 end def run order.order_line_items.new(buyable: buyable, unit_price: unit_price, amount: amount, extended_price: extended_price, processing_fee: processing_fee, price_paid: price_paid, discount: discount) end
  48. Coupon Code application logic require "rails_helper" describe CouponCode do describe

    "applicability" do let(:trip) { Trip.new } let(:hotel) { Hotel.new } let(:activity) { Activity.new } let(:code) { CouponCode.new } describe "a general code" do before { code.applies_to = :all } specify { expect(code.ok_for(trip)).to be_truthy } specify { expect(code.ok_for(hotel)).to be_truthy } specify { expect(code.ok_for(activity)).to be_truthy } end describe "a specific code" do before { code.applies_to = :trip } specify { expect(code.ok_for(trip)).to be_truthy } specify { expect(code.ok_for(hotel)).to be_falsy } specify { expect(code.ok_for(activity)).to be_falsy } end end end
  49. The Code class CouponCode < ActiveRecord::Base has_many :order_line_items def ok_for(buyable)

    return true if applies_to.blank? || applies_to == "all" buyable.class.name.downcase == applies_to end end
  50. The Call def coupon_code_applies? coupon_code && coupon_code.ok_for(buyable) end def discount

    return 0 unless coupon_code_applies? (coupon_code.discount_percentage * extended_price) / 100.0 end
  51. And then... 4 Limit on number of times a user

    can use a code 4 Maximum discount limit on code 4 Code applies to specific trip, hotel, or activity 4 Code only applies when used during a specific date range
  52. Using our fake API class AvailabilityApi def self.available?(api_id, date_string) sleep(rand

    * 5) result = rand if result < 0.95 true elsif result < 0.99 false else sleep(rand * 10) raise "Ooops" end end end