Boring Rails

Nintendo is known for using boring technologies. Not the worst plan. Here are some tips on handling complexity on Rails in a boring, pragmatic way I’ve collected to share with the team.

Table of Content

Models Hierarchy

models/
  blog_post/
    category.rb
    vote.rb
  code_review/
    submission/
      comment.rb
    submission.rb
  company/
    group/
      member.rb
  course/
    build.rb
    challenge.rb
# app/models/course/challenge.rb

class Course::Challenge < ApplicationRecord
  validates :exercise
  belongs_to :exercise, inverse_of: :course_challenge
  belongs_to :course
  ...
end

State machines

state_machine :stripe_state, initial: synchronized, namespace: stripe do
  state :synchronized
  state :synchronizing
  state :invalidated

  event :invalidate do
    transition all: :invalidated
  end

  event :synchronize do
    transition invalidated: :synchronized
  end

  event :mark_as_synchronized do
    transition synchronizing: :synchronized
  end
end

Repositories

# app/repositories/lesson_repository.rb

module LessonRepository
  extend ActiveSupport::Concern
  include StateMachine

  included do
    scope :web, -> { approved.joins(:course).merge(Course.web) }
    scope :with_locale, ->(locale) { joins(:course).where(courses: { locale: locale }) }
    scope :with_members, -> { joins("...") }
    ...

Presenters

# app/presenters/user_presenter.rb

module UserPresenter
  def public_name
    return full_name if full_name.present?
    return username if username?
  end

Form Objects

ActiveModel

  • Extract validations
class PersonForm
  attr_accessor :name, :email

  include ActiveModel::Model
  validates :name, :email, presence: true
end
def create
  if person_form.valid?
    @user = User.create(person_form.as_json)
    ...

Shapes

  • Different set of validations
  • Creates an original model
  • STI and Conditional Validations replacement
@user = UserRegistrationShape.create(user_params)
class UserRegistrationShape < User
  include ApplicationShape

  validated_confirmation_of :email
  validates_acceptance_of :terms_of_service
end
module ApplicationShape
  extend ActiveSupport::Concern

  module ClassMethods
    delegate :model_name, to: :superclass
    delegate :name, to: superclass
  end
end

Forms Validation and Data Normalization

# app/forms/user/sign_up_form.rb

class User::SignUpForm < User
  include ActiveFormModel

  permit :email, :password, :first_name

  validates :password, presence: true, length: { minimum: 8 }

  def email=(email)
    if email.present?
      write_attribute(:email, email.downcase)
    else
      super
    end
  end
end

Controllers Hierarchy

controllers/
  web/
    projects/
      members/
        comments_controller.rb
    application_controller.rb
  application_controller.rb
# app/controllers/web/projects/members/comments_controller.rb

class Web::Projects::Members::CommentsController < Web::ApplicationController
  ...

Authorization

Layer above models

# app/policies/resume/answer/comment_policy.rb

class Resume::Answer::CommentPolicy < ApplicationPolicy
  def edit?
    author?
  end

  def destroy?
    author?
  end

  def update?
    author?
  end
end

Mutators

  • Callbacks replacement.
  • Anything more complex than create(params) goes to mutators.
  • Nothing except working with entity
# app/mutators/resume/answer_mutator.rb

module Resume::AnswerMutator
  def self.create(resume, params, current_user)
    answer = resume.answers.build params
    answer.user = current_user
    resume.user.notifications.create!(kind: :new_answer, resource: answer) if answer.save
    answer
  end
end

Service Layer

# app/services/user_service.rb

class UserService
  def self.update_current_tutor(user)
    UserMutator.assign_tutor!(user)
    AnalyticsSender.track(:tutor_assigned, user)
    true
  end
end
class Web::Projects::Members::CommentsController < Web::Projects::Members::ApplicationController
  before_action :require_email_confirmation!

  def create
    member = Project::Member.find(params[:member_id])
    authorize member, :create_comment?

    comment = ProjectService.create_comment(member, current_user, permitted_params)
    if comment.persisted?
      f(:success)
    else
      f(:error)
    end
    redirect_to project_member_path(member.project, member)
  end

  private

  def permitted_params
    params.require(:project_member_comment).permit(:body)
  end

Dependency Inversion

if Rails.env.production?
  register :active_campaign, -> { ActivecampaignManager.new configus.ac.api.token }
else
  register :active_compaign, -> { ActivecampaignManagerStub.new }
end

if Rails.env.test?
  register :gitlab_klass, -> { GitlabStub }
else
  register :gitlab_klass, -> { Gitlab }
end
class AmplitudeJob < ApplicationJob
  include Import['amplitude_klass']

  def perform(event_name, event_data, user_data, options)
    name = EventsMapping.amplitude_names(event_name)
    data = { }
    event = AmplitudeAPI::Event.new(data)
    amplitude_klass.track(event)
  end
end

Null Object

class Guest
  def id; end

  def created_at
    Time.current
  end

  def employee
    nil
  end

  def authenticate(_password)
    false
  end
end
def current_user
  @current_user ||= User.find_by(id: session[:user_id]) || Guest.new
end

Async

  • https://github.com/mperham/sidekiq/wiki/Monitoring#monitoring-queue-latency
# sync method
def update_post_users!
  ...
end

# async method
def update_post_users
  UpdatePostUsersJob.perform_later(self.id)
end
# job
class UpdatePostUsersJob < ApplicationJob
  def perform(post_id)
    post = Post.find_by(id: post_id)
    post&.update_post_users!
  end
end

Business Logic

  • Business-logic kept isolated from implementation details - Services, Interfaces, DB
  • Inject everything
  1. Controller - Business
  2. Business - Controller
  3. Business - Business
  4. DB - Business

1. Controller - Business

  • Operation is a good pattern-free word
  • Unified Interface
  • Railway oriented programming
app/
  controllers/
    clients_controller.rb
  operations/
    clients/
      confirmations/
        start.rb
def create
  operation = Clients::Confirmations::Start.new
  handle_result operation.call(transaction: transaction)
end

Return result

class Clients::Confirmations::Start < Operation
  def call(transaction:)
    valid = transaction_eligible? transaction
    return ??? unless valid

    code = generate_code transaction
    return ??? unless code

    result = send_code code
    return ??? unless code

    transaction ???
  end
end
class Clients::Confirmations::Start < Operation
  def call(transaction:)
    # Success() or Failure(...)
    yield transaction_eligible? transaction
    # Success(code) or Failure(...)
    code = yield generate_code transaction
    # Success() or Failure(...)
    yield send_code code

    Success(transaction)
  end
end

Data Validation

  • Validate user data not domain objects
  • Always valid objects
  • dry-rb/dry-validation[https://dry-rb.org/gems/dry-validation/]
class Profile < ApplicationRecord
  validates :name, :birthdate, presence: true
class ProfileContract < Contract
  params do
    required(:name).filled(:string)
    required(:birthdate).value(:date)

2. Business - Controller

def create
  operation = Clients::Confirmations::Start.new
  handle_result operation.call(transaction: transaction)
end
def handle_result(result)
  case result
  when Success() then head(:ok)
  when Success then respond_with_data(result.value!)
  when Failure then respond_with_error(result.failure)
  end
end

3. Business - Business

module Confirmations
  class Start < Operation
    option :generate_code_command,
      reader: :private,
      default: -> { ConfirmationCodes::Generate.new }

    def call(transaction:)
      code = yield generate_code_command.call(source: transaction)
      ...

Testing

describe Clients::Confirmations::Start do
  describe "#call" do
    described_class.new(generate_code_command: generate_code_command).call
  end

  let(:generate_code_command) { instance_double(ConfirmationCodes::Generate) }

  before do
    allow(generate_code_command).to receive(:call).with(...).and_return(...)
  end

  ...

Chaining

  • Railway oriented programming https://vimeo.com/113707214
  • Command-Query Segregation
   steps
   1   2   3
---*---*---*------ happy path
    \   \   \
------------------ error
class Clients::Confirmations::Start < Operation
  def call(transaction:)
    # Success() or Failure(...)
    yield transaction_eligible? transaction
    code = yield generate_code transaction
    yield send_code code
     Success(transaction)
  end
end

4. DB - Business

  • Inject repository
  • Single Responsibility Principles
module Confirmations
  class Generate < Operation
    option :codes_repo,
      reader: :private,
      default: -> { ConfirmationCode }

  private

  def persist_code(code_attributes)
    codes_repo.create!(code_attributes)
  end
end

Transactions

module Confirmation
  class Verify < Operation
    option :repo,
      reader: :private,
      default: -> { ApplicationRecord }

    def call(transaction:, confirmation_code:)
      repo.transaction do
        yield confirm_code(code)
        yield confirm_transaction(transaction)
      end
    end

Sources:

Hello!

Nikolay Ostrovsky

Startup Team Lead. I like everything minimal and as simple as possible.

Grew from Web and Graphics through User Experience and Interfaces, Backend and Frontend programming to Team Lead and CTO. Contributed to projects for major brands. L'Oreal, JTI and AB InBev among others.