Gemsavvy

How to build a Rails app

in 48 hours without using the RailsWay™

Project

  • 4 remote developers
  • 48 hours
  • Rails app

Setup

 

Outcome

  • Working project
  • > 100 Pull Request
  • > 250 commits
  • > 60 issues closed
  • Only 4 hours w/o commits
  • 8th (over 128 projects)
 

Share your jewels with your community

 

Caution

 

This talk is the exact opposite of the RailsWay™.

 

If you really like the RailsWay and have heart disease, this talk might be dangerous for you.

 

Layers

Model

 

Service

 

Query

 

Validation

 

Controller

 

Context

 

Presenter

 

Template

 

Helper

 

Do

  • Define relationships
  • Represent data
  • Store data

Don't do

  • Define queries / scopes
  • Define validations
  • Define business actions
  • Define callbacks
 

Models

Queries

module Gempackages
  class StatsQuery < BaseQuery
    @model = Gempackage

    module Scopes
      def maximum_usage(threshold)
        group(:id)
        .having('count(*) <= ?', threshold)
        .select('count(*) as usage_count, gempackages.*')
      end

      def maximum_stargazers(threshold)
        where('github_stars <= ?', threshold)
      end

      def minimum_stargazers(threshold)
        where('github_stars >= ?', threshold)
      end

      def sort_by_top_stargazers
        order(github_stars: :desc)
      end
    end
  end
end

Queries

Good​​

 

Bad

 
  • Don't clutter model
  • Still reusable
  • Clear interface for user
 
  • Bad performance
 
Gempackages::StatsQuery
  .all(survey.gempackages)
  .minimum_stargazers(Settings.gempackages.outsiders_stargazers_min_threshold)
  .maximum_stargazers(Settings.gempackages.outsiders_stargazers_max_threshold)
  .merge(not_much_used_gems)
  .sort_by_top_stargazers
  .limit(Settings.gempackages.outsiders_max_threshold)

Forms

class GemfileForm < Reform::Form
  property :owner_name
  property :document, virtual: true

  validates :owner_name, presence: true
  validates :document, presence: true

  validates :document, file_size: { less_than: 2.megabytes },
                       file_content_type: { allow: 'application/octet-stream' }

  validate :document_is_gemfile

  private

  def document_is_gemfile
    return if document.nil?

    Bundler::Definition.build(document.tempfile, nil, nil)
  rescue
    errors.add(:document, 'must be a Gemfile')
  end
end

Forms

 

Good​​

 

Bad

 
  • Don't clutter model
  • Can be context dependent
 
  • Can't perform DB validation
 
gemfile = Gemfile.new
form = GemfileForm.new(gemfile)

fail Errors::ValidationError.new({ form: form }) unless form.validate(params)

form.sync

Services

module Gemfiles
  class CreateService < BaseService

    # `attr_reader`s and `initialize`...

    def call
      fail Gemfiles::ClosedSurveyError.new(survey) if survey.closed?
      fail Errors::ValidationError.new({ form: form }) unless form.validate(params)

      form.sync
      gemfile.assign_attributes attributes
      gemfile.save!

      perform_job(Gemfiles::ImportJob, gemfile.id)

      gemfile
    end

    private

    # helper methods
  end
end

Services

 

Good​​

 

Bad

 
  • Don't clutter model
  • Can return anything
  • Can easily handle pre/post actions
 
  • no idea ¯\_(ツ)_/¯
 
def foo
  gemfile_creator = Gemfiles::CreateService.new(GemfileForm, survey.id, params[:gemfile])
  gemfile = gemfile_creator.call
rescue Errors::ValidationError => exception
  # do some stuff
end

Presenters

class SurveyPresenter

  private

  attr_reader :survey

  public

  def initialize(survey)
    @survey = survey
  end

  def period
    opening_on = I18n.l(survey.created_at.to_date, format: :long)
    closing_on = I18n.l(survey.closing_on, format: :long)

    "#{opening_on} - #{closing_on}"
  end

  def when_is_open(&block)
    block.call unless survey.closed?
  end
end

Presenters

 

Good​​

 

Bad

 
  • Don't clutter model
  • Remove the conditional from the template
  • Easily testable
 
  • no idea ¯\_(ツ)_/¯
 
survey = ::Surveys::FindByCodeService.new(group.id, params[:id]).call
@survey_presenter = SurveyPresenter.new(survey)

Contexts​

 
module Surveys
  class ShowContext

    private

    attr_reader :survey, :stats

    public

    delegate :logo_url, to: :group, prefix: true

    delegate :when_is_open, to: :survey_presenter

    def initialize(survey, stats)
      @survey = survey
      @stats  = stats
    end

    private

    def survey_presenter
      @survey_presenter ||= SurveyPresenter.new(survey)
    end
  end
end

(should be named views!)

 

Contexts

 

Good​​

 

Bad

 
  • Send only one variable to the template
  • Easily testable
 
  • Can be hard to decide where a method belong
 
def show
  survey = ::Surveys::FindByCodeService.new(group.id, params[:id]).call
  stats = ::Surveys::GenerateStatsService.new(survey.id).call

  @context = ::Surveys::ShowContext.new(survey, stats)
end

Controllers

 
module Groups
  class SurveysController < Groups::BaseController
    before_action :authorize!, only: [:new, :create, :edit, :update]

    def create
      survey = ::Surveys::CreateService.new(SurveyForm, group.id, params[:survey]).call
      flash[:notice] = "Survey #{survey.name} has been succesfully created"
      redirect_to group_path(group, token: params[:token])
    rescue Errors::ValidationError => exception
      @context = ::Surveys::ActionContext.new(group, exception.context[:form])
      flash.now[:alert] = 'We were not able to create your survey'
      render :new
    end

    def show
      stats = ::Surveys::GenerateStatsService.new(survey.id).call
      @context = ::Surveys::ShowContext.new(survey, stats)
    end

    # ... other actions ...

    private

    def survey
      @survey ||= ::Surveys::FindByCodeService.new(group.id, params[:id]).call
    end
  end
end

Controllers

 

Do

 

Don't do

 
  • Ask/Send data to services
  • Give data to contexts
  • Handle user request flow (redirections, errors,...)
 
  • Initialize/Save models
  • Execute queries/scopes
  • Manipulate data
  • Validate data
 

Helpers

 
module MaterializeHelper
  Flash = Struct.new(:title, :classes)

  FLASH_TYPES = {
    "alert"  => MaterializeHelper::Flash.new("Oooops!",    "error"),
    "notice" => MaterializeHelper::Flash.new("Well done!", "success")
  }

  def flash_type_to_class_names(type)
    FLASH_TYPES[type].classes
  end

  def flash_type_to_title(type)
    FLASH_TYPES[type].title
  end
end

Helpers

 

Do​​

 

Don't do

 
  • format purely template related content
 
  • format business objects
  • compute data on business objects
 

That's all folks!

 

Website:

 

Source code:

 

http://gemsavvy.tips

 

http://github.com/ruby-nord/gemsavvy

 

The project is Open Source,

don't hesitate to come by and contribute.

 

Gemsavvy

By Kevin Disneur

Gemsavvy

How to build a Rails app in 48 hours without the RailsWay

  • 2,303