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

ABExperimentBuilder: Applying the Module Builder Pattern

ABExperimentBuilder: Applying the Module Builder Pattern

A walkthrough of a refactor in Ruby illustrating how to apply the Module Builder pattern. Also introduces some basic metaprogramming techniques that are more widely-applicable.

Much appreciation to Chris Salzberg who coined the pattern and discusses it in-depth here: http://dejimata.com/2017/5/20/the-ruby-module-builder-pattern

Bradley Schaefer

July 16, 2017
Tweet

More Decks by Bradley Schaefer

Other Decks in Programming

Transcript

  1. A/B Experiments The Way Things Were class BodyShape attr_reader :api,

    :client NAME = 'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  2. A/B Experiments The Way Things Were class ItemTypeGroupPreferences attr_reader :api,

    :client NAME = 'New Womens Item Type Group Preferences' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end def show_new_womens_item_type_group_preferences_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  3. A/B Experiments The Way Things Were class PrimaryObjectives attr_reader :api,

    :client NAME = 'New Womens Primary Reasons Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end def show_primary_objectives_question? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  4. A/B Experiments The Way Things Were class WomensBrands attr_reader :api,

    :client NAME = 'New Womens Brands Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end def show_womens_brands_question? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  5. A/B Experiments The Way Things Were class BottomsAttributePreferences attr_reader :api,

    :client NAME = 'New Womens Bottoms Attribute Preferences' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end def show_new_bottoms_attribute_preferences_question? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  6. What Stands Out? class BodyShape attr_reader :api, :client NAME =

    'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new( experiment_name: NAME, test_subject: client ) @client = client end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end class PrimaryObjectives attr_reader :api, :client NAME = 'New Womens Primary Reasons Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new( experiment_name: NAME, test_subject: client ) @client = client end def show_primary_objectives_question? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  7. Duplication! class BodyShape attr_reader :api, :client NAME = 'New Womens

    Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new( experiment_name: NAME, test_subject: client ) @client = client end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end class PrimaryObjectives attr_reader :api, :client NAME = 'New Womens Primary Reasons Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new( experiment_name: NAME, test_subject: client ) @client = client end def show_primary_objectives_question? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  8. Removing Duplication class BodyShape attr_reader :api, :client NAME = 'New

    Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  9. Replace all initializers class BodyShape include ABExperiment attr_reader :api, :client

    NAME = 'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def initialize(client) @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  10. Looking better! class BodyShape include ABExperiment attr_reader :api, :client NAME

    = 'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  11. But wait… there's a problem module ABExperiment def initialize(client) @api

    = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end end
  12. But wait… there's a problem module ABExperiment def initialize(client) @api

    = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end end Let's run the tests…
  13. But wait… there's a problem module ABExperiment def initialize(client) @api

    = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end end Failure/Error: @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) NameError: uninitialized constant ABExperiment::NAME
  14. But wait… there's a problem module ABExperiment def initialize(client) @api

    = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end end Need a way to look up a constant in the correct scope
  15. But wait… there's a problem module ABExperiment def initialize(client) @api

    = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) @client = client end end Failure/Error: @api = StitchFix::Experiments::API.new(experiment_name: NAME, test_subject: client) NameError: uninitialized constant ABExperiment::NAME
  16. const_get does the trick module ABExperiment def initialize(client) @api =

    StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) @client = client end end
  17. What stands out now? class BodyShape include ABExperiment attr_reader :api,

    :client NAME = 'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  18. What stands out now? class BodyShape include ABExperiment attr_reader :api,

    :client NAME = 'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  19. Encapsulate api/client module ABExperiment attr_reader :api, :client def initialize(client) @api

    = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) @client = client end end
  20. Encapsulate api/client class BodyShape include ABExperiment attr_reader :api, :client NAME

    = 'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  21. Looking even better! class BodyShape include ABExperiment NAME = 'New

    Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  22. What stands out now? class BodyShape include ABExperiment NAME =

    'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  23. What stands out now? class BodyShape include ABExperiment NAME =

    'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def self.define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  24. We can extract that too! module ABExperiment extend ActiveSupport::Concern #

    ... module ClassMethods def define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end end end
  25. Oh yeah, I've seen this before module ABExperiment extend ActiveSupport::Concern

    # ... module ClassMethods def define! StitchFix::Experiments::API.define_experiment do name NAME treatment CONTROL_CELL, control: true treatment TEST_CELL allocation Time.now..6.months.from_now live true owner Client.first end end end end
  26. Oh yeah, I've seen this before module ABExperiment extend ActiveSupport::Concern

    # ... module ClassMethods def define! StitchFix::Experiments::API.define_experiment do name const_get(:NAME) treatment const_get(:CONTROL_CELL), control: true treatment const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end end end
  27. Oh yeah, I've seen this before module ABExperiment extend ActiveSupport::Concern

    # ... module ClassMethods def define! StitchFix::Experiments::API.define_experiment do name const_get(:NAME) treatment const_get(:CONTROL_CELL), control: true treatment const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end end end Let's run the tests…
  28. Not the expected scope Failure/Error: name const_get(:NAME) NoMethodError: undefined method

    `const_get' for #<StitchFix::Experiments::DSL: 0x007fc103dc8e28> def define! StitchFix::Experiments::API.define_experiment do name const_get(:NAME) treatment const_get(:CONTROL_CELL), control: true treatment const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end
  29. Let's fix that Failure/Error: name const_get(:NAME) NoMethodError: undefined method `const_get'

    for #<StitchFix::Experiments::DSL: 0x007fc103dc8e28> def define! klass = self StitchFix::Experiments::API.define_experiment do name klass.const_get(:NAME) treatment klass.const_get(:CONTROL_CELL), control: true treatment klass.const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end
  30. module ABExperiment extend ActiveSupport::Concern included do attr_reader :client, :api end

    def initialize(client) @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end module ClassMethods def define! klass = self StitchFix::Experiments::API.define_experiment do name klass.const_get(:NAME) treatment klass.const_get(:CONTROL_CELL), control: true treatment klass.const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end end end
  31. Looking really good! class BodyShape include ABExperiment NAME = 'New

    Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  32. There's something still bothering me class BodyShape include ABExperiment NAME

    = 'New Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  33. An Encapsulation Problem class BodyShape include ABExperiment NAME = 'New

    Womens Body Shape Question' CONTROL_CELL = 'Control' TEST_CELL = 'New Question' def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  34. Wouldn't this be nice? class BodyShape include ABExperimentBuilder.new( name: 'New

    Womens Body Shape Question', control_cell: 'Control', test_cell: 'New Question' ) def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  35. class ABExperimentBuilder < Module def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME,

    name) const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end module ClassMethods def define! klass = self StitchFix::Experiments::API.define_experiment do name klass.const_get(:NAME) treatment klass.const_get(:CONTROL_CELL), control: true treatment klass.const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end end def included(base) base.extend ClassMethods end end
  36. class ABExperimentBuilder < Module def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME,

    name) const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end module ClassMethods def define! klass = self StitchFix::Experiments::API.define_experiment do name klass.const_get(:NAME) treatment klass.const_get(:CONTROL_CELL), control: true treatment klass.const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end end def included(base) base.extend ClassMethods end end
  37. Module is a Class module ABExperiment # … end ABExperiment

    = Module.new do # … end Module.class # => Class
  38. class ABExperimentBuilder < Module def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME,

    name) const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end module ClassMethods def define! klass = self StitchFix::Experiments::API.define_experiment do name klass.const_get(:NAME) treatment klass.const_get(:CONTROL_CELL), control: true treatment klass.const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end end def included(base) base.extend ClassMethods end end
  39. ABExperimentBuilder builds Modules def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME, name)

    const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end
  40. ABExperimentBuilder builds Modules def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME, name)

    const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end
  41. ABExperimentBuilder builds Modules def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME, name)

    const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end
  42. ABExperimentBuilder builds Modules def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME, name)

    const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end
  43. ABExperimentBuilder builds Modules def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME, name)

    const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end ABExperimentBuilder.new( name: 'New Womens Body Shape Question', test_cell: 'New Question' )
  44. This is what a built module would look like module

    ABExperimentTemplate NAME = 'Foo' CONTROL_CELL = 'Control' TEST_CELL = 'Test' attr_reader :api, :client def initialize(client) @client = client @api = StitchFix::Experiments::API.new( experiment_name: NAME, test_subject: client ) end end ABExperimentBuilder.new( name: 'Foo', test_cell: 'Test' ) {
  45. class ABExperimentBuilder < Module def initialize(name:, test_cell:, control_cell: 'Control') const_set(:NAME,

    name) const_set(:CONTROL_CELL, control_cell) const_set(:TEST_CELL, test_cell) attr_reader :api, :client define_method(:initialize) do |client| @client = client @api = StitchFix::Experiments::API.new( experiment_name: self.class.const_get(:NAME), test_subject: client ) end end module ClassMethods def define! klass = self StitchFix::Experiments::API.define_experiment do name klass.const_get(:NAME) treatment klass.const_get(:CONTROL_CELL), control: true treatment klass.const_get(:TEST_CELL) allocation Time.now..6.months.from_now live true owner Client.first end end end def included(base) base.extend ClassMethods end end
  46. You don't need a Concern module ClassMethods # … end

    def included(base) base.extend ClassMethods end
  47. The Final Result class BodyShape include ABExperimentBuilder.new( name: 'New Womens

    Body Shape Question', control_cell: 'Control', test_cell: 'New Question' ) def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  48. The Interesting Bits, Front and Center class BodyShape include ABExperimentBuilder.new(

    name: 'New Womens Body Shape Question', control_cell: 'Control', test_cell: 'New Question' ) def show_new_body_shape_questions? api.allocating?(time: client.create_date) && api.in_cell?(cell_name: TEST_CELL) end end
  49. Tradeoffs Good Bad Experiment Class Readability I wrote a talk

    to explain the code in ABExperimentBuilder Can test module builder code and be confident it works across all experiments Too much magic? It's a neat Ruby technique metaprogramming: bad Didn't change tests/interface Changing interface potentially valuable