Maintaining a 5yo Ruby Project (Shark Edition)

Maintaining a 5yo Ruby Project (Shark Edition)

Presented at TropicalRuby 2015.

Maintaining a young, small Ruby product is simple, but time passes and your code becomes harder to maintain day after day. This talk illustrates the development techniques, Ruby patterns and best practices we use at DNSimple to develop new features and ensure long-term maintainability of our codebase.

Note: This is a special "AWARE Shark Conservation" edition of the talk including a special section dedicated to Shark conservation, inspired by the "Shark Danger" signal in the Recife beaches.

99e0b39c091e10d9c7d4452a34ca52dc?s=128

Simone Carletti

March 06, 2015
Tweet

Transcript

  1. 2.
  2. 4.
  3. 5.
  4. 6.
  5. 8.
  6. 9.
  7. 10.
  8. 11.
  9. 16.
  10. 17.
  11. 18.
  12. 19.
  13. 20.
  14. 21.
  15. 23.
  16. 24.
  17. 25.
  18. 26.
  19. 27.
  20. 29.

    It's mostly a Ruby app It contains Ruby, Go and

    some other languages. This is just a part of DNSimple. ! " # $ % &
  21. 30.

    It was a monolithic Rails app And it's sIll a

    big Rails-based app ! " # $ % &
  22. 31.

    This is not a talk about Rails But it may

    contain Rails code ! " # $ % &
  23. 35.
  24. 38.

    convention |kənˈvɛnʃ(ə)n| 1 a way in which something is usually

    done: to attract the best patrons the movie houses had to ape the conventions and the standards of theatres. • [ mass noun ] behaviour that is considered acceptable or polite to most members of a society: he was an upholder of convention and correct form | [ count noun ] : the law is felt to express social conventions. 2 an agreement between states covering particular matters, especially one less formal than a treaty. the convention, signed by the six states bordering on the Black Sea, aims to prevent further pollution. ! " # $ % &
  25. 45.

    pattern |ˈpat(ə)n| 2 a regular and intelligible form or sequence

    discernible in the way in which something happens or is done: a complicating factor is the change in working patterns. 4 an excellent example for others to follow: he set the pattern for subsequent study. ! " # $ % &
  26. 47.
  27. 52.
  28. 53.
  29. 55.
  30. 57.
  31. 58.
  32. 66.

    require 'phony' module Dnsimple # Phone is a wrapper for

    the current phone validation library. # # It provides a custom API to the Phony validation library, as well some additional DNSimple-oriented features, # such as a whitelist approach that allows us to define a list of always allowed numbers. # # The whitelist was introduced because of the need to provide an immediate solution for users trying to purchase # a service and being stopped by a validation bug. module Phone WHITELIST = { "+389123456789" => "+389 123456789", } # Makes a plausibility check on the number. # # If the number is in whitelist, the method always returns true. # Otherwise Phony is used. # # number - The String phone number # # Returns true if it's a plausible phone number. def self.plausible?(number) whitelisted?(number) || Phony.plausible?(number) end def self.format(number) whitelisted?(number) || Phony.formatted(Phony.normalize(number), format: :international) end def self.whitelist WHITELIST end def self.whitelisted?(input) number = input.gsub(/[^\d\+]/, "") whitelist[number] end end end
  33. 67.

    module Dnsimple module Phone WHITELIST = {...} # Makes a

    plausibility check on the number. # # If the number is in whitelist, the method always returns true. # Otherwise Phony is used. # # number - The String phone number # # Returns true if it's a plausible phone number. def self.plausible?(number) whitelisted?(number) || Phony.plausible?(number) end def self.format(number) whitelisted?(number) || Phony.formatted(Phony.normalize(number), format: :international) end def self.whitelist WHITELIST end def self.whitelisted?(input) number = input.gsub(/[^\d\+]/, "") whitelist[number] end end end
  34. 70.

    module Dnsimple class AliasResolver class << self def adapter @adapter

    ||= NullAdapter.new end # .resolve the ALIAS name and returns a result. # # name - The String FQDN to resolve. # # Returns a AliasResolver::Result. def resolve(name) adapter.resolve(name) end # .enable_test! enables the test adapter. # # Returns nothing. def enable_test! self.adapter = test_adapter.new end end
  35. 71.

    # TestAdapter is a special adapter designed for unit testing.

    # # It provides the following extra capabilities: # # - stub: to stub a specific resolver call # - clear: to clear the stub list # class TestAdapter def resolve(*) result = @stubs.shift result ? Result.new(*result) : Result.new end def stub(name = nil, ipv4: [], ipv6: [], error: nil) @stubs.push [ipv4, ipv6, error] end end # GoAdapter is an adapter that relies on the dnsimple-alias go binary. class GoAdapter BIN = File.join(Rails.root, "bin", "dsalias") def resolve(name) # ... end end
  36. 72.

    # This is a configuration line in our test suite

    Dnsimple::AliasResolver.enable_test! describe "a methods that interacts with the resolver" do before do AliasResolver.adapter.test :stub, "fully.qualified.host" end it "does something" do # ... end end
  37. 74.

    Just to name a few • Methods defined in AR::Base

    are not allowed outside the Models • Models must expose custom API to perform operaIons ! " # $ % &
  38. 75.

    Just to name a few • Callbacks are allowed only

    for data integrity • Callbacks should not interact with other enIIes ! " # $ % &
  39. 76.

    Just to name a few • Finder methods in AR::Base

    are not allowed outside the Models or Finders • Scopes can't be invoked directly outside Models or Finders • Scopes exists only to support Finders * ! " # $ % &
  40. 77.

    class TaskLog < ActiveRecord::Base def self.log!(name, metadata = nil) metadata

    = metadata.inject([]) { |t, (k,v)| t << "#{k.to_s}:#{v.to_s}" }.join("/") if metadata create!(name: name, logged_at: Time.current, metadata: metadata) end def self.logged_less?(name, times) where("name = ?", name).count < times end def self.logged_since?(name, time) where("name = ? AND logged_at > ?", name, time).count != 0 end def system? name ~= /^system\./ end end
  41. 80.

    class ArpaDomain < ActiveRecord::Base include ModelPersistence # more code here

    module Persistence def create_arpa_domain(name, account, attributes = {}) self.attributes = attributes self.name = name self.account = account optionally_parse creating do assign_to_auth_name_server create_system_records async_zone_create end end def delete_arpa_domain transaction do if destroy # :async_zone_delete true end end end end include Persistence end
  42. 85.
  43. 86.

    require_relative 'params_allowed' class ContactParams include ParamsAllowed def self.allowed [:label, :first_name,

    :last_name, :address, :city, :state, :postal_code, :country, :email, :phone] end end # Params encapsulate the logic to handle some parameters from an untrusted source. # # A Params object generally doesn't include the Param module directly. Instead, it relies to one of the # more specific implementations, such as ParamsAllowed. module Params def initialize(params) @params = ActiveSupport::HashWithIndifferentAccess.new(params.to_h) end end module ParamsAllowed def self.included(base) base.include Params end def to_h @params.slice(*self.class.allowed).to_hash.symbolize_keys! end end
  44. 87.
  45. 88.
  46. 90.

    Pulling out methods from a large class into mixins is

    like hiding the dust under the carpet. ! " # $ % &
  47. 92.

    It looks cleaner at the surface, but it actually makes

    it harder to iden=fy and implement the decomposi=ons and extrac=ons necessary to clarify the domain model. ! " # $ % &
  48. 93.
  49. 94.

    module ZoneWorkerConcern def errors @errors ||= [] end def finish

    unless errors.empty? raise Dnsimple::ZoneServerError.new(errors.join("\n")) end end protected def domain_name options['name'] end def http_options {basic_auth: {username: C_.zone_server_identifier, password: C_.zone_server_token}} end end
  50. 98.
  51. 99.

    class ContactCreateCommand include Command::Command def execute(account, contact_attributes) contact = Contact.new

    if contact.create_contact(account, contact_attributes.to_h) Activity.track_contact(context.actor, contact, :create) else result.error = I18n.t("app.message_error_validation_failed") end ensure result.value = contact end end
  52. 100.

    def create @result = ContactCreateCommand.execute(command_context, this_account, contact_params) @contact = @result.value

    respond_to do |format| if @result.successful? format.html { redirect_to contact_path(@contact), notice: t("flash.contact_create_success") } format.json { render json: @contact, status: 201 } else format.html { render action: "new" } format.json { render json: { message: @result.error, errors: @contact.errors }, status: 400 } end end end def create @result = ContactCreateCommand.execute(command_context, this_account, contact_params) @contact = @result.value if @result.successful? redirect_to contact_path(@contact), notice: t("flash.contact_create_success") else render action: "new" end end
  53. 102.

    class CertificateSubmitCommand include Command::Command def execute(certificate, approver_email) return if error_certificate_notpaid(certificate)

    certificate.approver_email = approver_email certificate.do_submit rescue ActiveRecord::RecordInvalid => e result.error = I18n.t("app.message_error_validation_failed") rescue Enom::EnomError => e result.error = e.message end private def error_certificate_notpaid(domain) if something result.error = "gimme money first!" end end end
  54. 104.

    class ContactsController < ApplicationController def index @contacts = Contact.where(account: current_account).order(:name)

    end end module Api::V2::Controllers::Contacts class Index include Lotus::Action def call(params) @contacts = Contact.where(account: current_account).order(:name) end end end
  55. 106.

    class ContactsController < ApplicationController def index @contacts = ContactFinder.account_contacts_listing(current_account) end

    end module Api::V2::Controllers::Contacts class Index include Lotus::Action def call(params) @contacts = ContactFinder.account_contacts_listing(current_account) end end end
  56. 108.

    class EmailForwardFinder include ModelFinder::Finder # Finds the email forward with

    identifier associated with domain. def self.find_domain_email_forward!(identifier, domain) domain_email_forwards(domain).find_by_id(identifier) or not_found!(identifier) end # Lists the domain email forwards. def self.domain_forwards_listing(domain) domain_email_forwards(domain).order(:to) end # Selects the domain email forwards in descending update order. def self.domain_recently_updated_forwards(domain) domain_email_forwards(domain).order(updated_at: :desc) end private def self.not_found!(identifier) raise(ActiveRecord::RecordNotFound, "Email forward `#{identifier}' not found") end def self.domain_email_forwards(domain) domain.email_forwards end end
  57. 110.

    class TemplateFinder include ModelFinder::Finder def self.find(identifier) case identifier when Fixnum,

    ModelFinder::PATTERN_ID find_by_id(identifier) else find_by_slug(identifier) end end def self.find!(identifier) find(identifier) or not_found!(identifier) end def self.find_by_id(identifier) scope.where(id: identifier).first end def self.find_by_slug(identifier) query = identifier.to_s.downcase scope.where(short_name: query).first end def self.find_permitted_template(identifier, context) scoped(context.templates).find(identifier) end
  58. 111.

    Finders advantages • Custom API • Reduce model complexity •

    Isolate queries • Avoid temptaIon to use AR methods ! " # $ % &