Blog › ICP guides

Ruby on Rails developer on retainer: Rails architecture, ActiveRecord, Sidekiq, and RSpec on monthly retainer

August 27, 2026 · ~22 min read

A SaaS billing platform built on Rails 7 had a slow admin dashboard. The OrdersController#index action averaged 4.2 seconds in production — acceptable at 200 orders, catastrophic at 20,000. The development team had added three includes calls over two years, each targeting the association someone had noticed in a New Relic trace, but the page still issued 847 queries per request. A fractional Rails architect on monthly retainer enabled Bullet in staging, replicated the index request, and read the Bullet log: Order was loading Customer on every row via order.customer, loading Product on every row via order.line_items.each { |li| li.product.name }, and loading Discount on every row via order.applied_discount. The three existing includes calls each targeted one association correctly, but none targeted the nested line_items: :product path — the heaviest loader.

The fix replaced three separate includes calls with a single includes(:customer, :applied_discount, line_items: :product) and added a covering index on orders(customer_id, status, created_at) to support the WHERE and ORDER BY clauses. Query count dropped from 847 to 4. Page response dropped from 4.2 seconds to 180 milliseconds. No new feature shipped. The second month’s work focused on Sidekiq queue architecture: all 14 job classes shared the default queue, and a PDF generation job running for 35 seconds was blocking invoice notification emails from delivering within the SLA. Redesigning to a three-queue topology — critical (concurrency 5), default (concurrency 10), low (concurrency 3) — eliminated the blocking pattern without adding infrastructure.

Ruby on Rails developers, Rails architects, and Rails consultants on monthly retainer — fractional Rails engineers, Ruby platform advisors, and Rails performance consultants — do their highest-value work in the ActiveRecord query architecture, Sidekiq job design, service object extraction, and RSpec test suite optimization that the engineering director defends to the CTO. This guide covers Rails architecture in depth, Ruby language features, Sidekiq patterns, RSpec and testing, ActiveRecord query design, and Sorbet type coverage — and how to structure a Ruby on Rails developer retainer that makes the hours behind each optimization visible.

Rails architecture

Rails’ convention-over-configuration philosophy accelerates feature development but concentrates business logic in models and controllers if left unguided. A Rails architect on retainer designs the layer boundaries: where business logic lives (service objects, interactors, form objects), how authorization is enforced (Pundit policies, before_action gates), how callbacks are constrained, and how Strong Parameters protect the persistence layer.

Service objects and command/query patterns

# Service object — plain Ruby class, single public method, explicit inputs/outputs:
class Orders::CreateService
  Result = Data.define(:success, :order, :errors)  # Ruby 3.2+ immutable value object

  def initialize(params:, user:)
    @params = params
    @user   = user
  end

  def call
    ActiveRecord::Base.transaction do
      order = Order.new(@params)
      order.user = @user

      if order.save
        NotificationJob.perform_later(order.id, :created)
        Result.new(success: true, order: order, errors: [])
      else
        raise ActiveRecord::Rollback
        Result.new(success: false, order: nil, errors: order.errors.full_messages)
      end
    end
  rescue ActiveRecord::Rollback
    Result.new(success: false, order: nil, errors: @order&.errors&.full_messages || [])
  end
end

# In the controller — thin controller calls the service, renders or redirects on result:
class OrdersController < ApplicationController
  before_action :authenticate_user!
  before_action :authorize_create!, only: [:create]

  def create
    result = Orders::CreateService.new(
      params: order_params,
      user: current_user
    ).call

    if result.success
      redirect_to order_path(result.order), notice: "Order created"
    else
      @order  = result.order || Order.new
      @errors = result.errors
      render :new, status: :unprocessable_entity
    end
  end

  private

  def order_params
    params.require(:order).permit(
      :status, :notes,
      line_items_attributes: [:product_id, :quantity, :unit_price, :_destroy]
    )
  end

  def authorize_create!
    authorize Order, :create?  # Pundit policy check
  end
end

# Pundit policy — authorization logic separate from business logic:
class OrderPolicy < ApplicationPolicy
  def create?
    user.active? && !user.suspended?
  end

  def update?
    user.admin? || record.user_id == user.id
  end

  def destroy?
    user.admin? && record.status == :draft
  end
end

ActiveRecord callbacks and concerns

# Callbacks — use sparingly; prefer after_commit for side effects (emails, jobs, APIs):
class Order < ApplicationRecord
  belongs_to :customer
  has_many   :line_items, dependent: :destroy
  has_one    :shipment

  # before_validation — set derived fields before validation:
  before_validation :set_status_defaults

  # after_commit — safe for side effects (fires after the DB transaction commits):
  after_commit :notify_customer, on: :create
  after_commit :sync_to_warehouse, on: [:create, :update], if: :status_changed?

  # after_save fires INSIDE the transaction — external calls here can leave DB + external state
  # inconsistent if the external call raises. Prefer after_commit for all external calls.

  # Avoid: callback that calls external API synchronously (blocks request, can fail mid-tx):
  # after_save :post_to_slack  # BAD — runs inside transaction, Slack failure rolls back save

  # Prefer: enqueue a job inside after_commit:
  # after_commit :enqueue_slack_notification, on: :create  # job handles retry, decouples tx

  private

  def set_status_defaults
    self.status ||= :draft
    self.currency ||= customer&.preferred_currency || "USD"
  end

  def notify_customer
    OrderMailer.created(self).deliver_later
  end

  def sync_to_warehouse
    WarehouseSyncJob.perform_later(id)
  end
end

# Concerns — reusable modules for cross-cutting behavior:
module Searchable
  extend ActiveSupport::Concern

  included do
    scope :search, ->(query) {
      where("name ILIKE :q OR description ILIKE :q", q: "%#{sanitize_sql_like(query)}%")
    }
  end

  class_methods do
    def full_text_search(query)
      # pg_search or pg_trgm based implementation
      where("to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', ?)", query)
    end
  end
end

Routing and before_action chains

# config/routes.rb — keep routes declarative; use constraints for API versioning:
Rails.application.routes.draw do
  # Namespace for admin — different authentication chain:
  namespace :admin do
    resources :orders, only: [:index, :show, :update]
    resources :customers, only: [:index, :show]
    root to: "dashboard#index"
  end

  # API versioning with constraints:
  namespace :api do
    namespace :v1 do
      resources :orders, only: [:index, :show, :create] do
        member do
          post :cancel
          post :refund
        end
        collection do
          get :pending
        end
      end
    end
  end

  # Shallow nesting — avoids deep URL hierarchies:
  resources :projects do
    resources :tasks, shallow: true  # /projects/:project_id/tasks (collection)
                                      # /tasks/:id (member) — not /projects/:id/tasks/:id
  end

  # Concern — DRY shared routes:
  concern :commentable do
    resources :comments, only: [:create, :destroy]
  end

  resources :posts, concerns: [:commentable]
  resources :videos, concerns: [:commentable]
end

# ApplicationController — before_action chain:
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
  before_action :set_locale
  before_action :track_request

  # Rate limiting with Rack::Attack (configured in initializers):
  rescue_from Rack::Attack::BlockedError, with: :rate_limited

  private

  def set_locale
    I18n.locale = current_user&.locale || http_accept_language.compatible_language_from(I18n.available_locales) || I18n.default_locale
  end

  def rate_limited
    render json: { error: "Rate limit exceeded" }, status: :too_many_requests
  end
end

ActiveRecord query design

ActiveRecord’s N+1 problem is the single most common Rails performance issue: loading a collection and then loading an association inside a loop. A Rails architect on retainer diagnoses N+1 patterns with Bullet, selects the correct eager loading strategy, and designs the database index layout that makes the resulting queries fast.

includes, preload, and eager_load

# Three eager loading strategies — each uses a different SQL pattern:

# 1. includes — Rails decides between preload and eager_load based on the query:
Order.includes(:customer, :line_items)
# Uses preload (two separate queries) when no WHERE/ORDER on the association.
# Uses eager_load (LEFT OUTER JOIN) when WHERE or ORDER references the association table.

# 2. preload — always two separate queries (SELECT + SELECT...WHERE id IN (...)):
Order.preload(:customer)
# SELECT * FROM orders;
# SELECT * FROM customers WHERE id IN (1, 2, 3, ...)
# Safe for has_many to avoid Cartesian product; does not allow WHERE on the association.

# 3. eager_load — always a LEFT OUTER JOIN:
Order.eager_load(:customer).where(customers: { tier: :premium })
# SELECT orders.*, customers.* FROM orders LEFT OUTER JOIN customers ON ...
# WHERE customers.tier = 'premium'
# Required when filtering or ordering by the association table. Produces Cartesian product
# for has_many — use preload instead for has_many unless you need the WHERE clause.

# Nested association loading:
Order.includes(line_items: { product: :category })
     .includes(:customer, :applied_discount)
# Loads in 5 queries total regardless of result count.

# scope — composable query fragments:
class Order < ApplicationRecord
  scope :recent,     -> { order(created_at: :desc) }
  scope :for_status, ->(status) { where(status: status) }
  scope :for_user,   ->(user) { where(user: user) }
  scope :with_total_gt, ->(amount) { where("total > ?", amount) }

  # Default scope — avoid except for soft-delete (adds hidden WHERE to every query):
  # default_scope { where(deleted_at: nil) }
end

# Query composition:
Order.recent.for_status(:pending).with_total_gt(100).limit(20)

# counter_cache — avoids COUNT(*) queries for association counts:
# Migration:
# add_column :customers, :orders_count, :integer, default: 0, null: false
class Order < ApplicationRecord
  belongs_to :customer, counter_cache: true  # increments customer.orders_count on save
end
# Usage (no query):
customer.orders_count   # reads from the column, no COUNT(*) query
# vs. customer.orders.count  — issues SELECT COUNT(*) FROM orders WHERE customer_id = ?

# Pluck — select a single column as an array (no model instantiation):
Order.where(status: :pending).pluck(:id)
# SELECT id FROM orders WHERE status = 'pending'
# Much faster than .map(&:id) — avoids building ActiveRecord objects for each row

# select — partial model loading (reduces memory when only a few columns are needed):
Order.select(:id, :status, :total, :customer_id).limit(100)
# Loads only the specified columns; accessing unloaded columns raises MissingAttributeError

Database indexes and EXPLAIN ANALYZE

# Migration — add indexes that support your most common query patterns:
class AddOrderIndexes < ActiveRecord::Migration[7.1]
  def change
    # Composite index for the orders index page (WHERE status ORDER BY created_at):
    add_index :orders, [:status, :created_at], name: :idx_orders_status_created

    # Partial index — only pending orders need fast lookups (much smaller index):
    add_index :orders, :customer_id,
      where: "status IN ('pending', 'processing')",
      name: :idx_orders_pending_by_customer

    # Covering index — includes all columns the query SELECTs (index-only scan):
    add_index :orders, [:customer_id, :status, :created_at],
      include: [:total, :currency],
      name: :idx_orders_covering

    # Unique index — enforces uniqueness at the database level (not just validation):
    add_index :orders, :external_reference_id, unique: true, name: :idx_orders_external_ref
  end
end

# EXPLAIN ANALYZE in rails console — read the query plan:
# Run: ActiveRecord::Base.connection.execute("EXPLAIN ANALYZE " + Order.where(...).to_sql)
# Key plan nodes:
# Seq Scan     — reading the entire table (missing index or small table)
# Index Scan   — using an index, fetching heap rows
# Index Only   — index-only scan (covering index, no heap fetch — fastest)
# Bitmap Heap  — using index bitmap + heap fetch (common for IN queries)
# Nested Loop  — join algorithm; watch for loop count × inner cost
# Hash Join    — join via hash table; good for large association sets
# Sort         — expensive if no index supports ORDER BY; watch for "Sort Method: external"

# Analyzing a specific slow query:
sql = Order.includes(:customer).where(status: :pending).order(created_at: :desc).to_sql
puts ActiveRecord::Base.connection.execute("EXPLAIN (ANALYZE, BUFFERS) #{sql}").map { |r| r["QUERY PLAN"] }.join("\n")

Sidekiq background job architecture

Sidekiq is the standard Rails background job processor. A Rails architect on retainer designs the queue topology (number of queues, weights, concurrency), implements idempotent job classes, configures retry and dead-letter handling, and wires ActionMailer and ActionJob correctly into the Sidekiq adapter.

Job design and idempotency

# Sidekiq 7 job class:
class Orders::NotifyCustomerJob
  include Sidekiq::Job

  # Queue configuration:
  sidekiq_options queue: "critical",
                  retry: 5,           # retry up to 5 times with exponential backoff
                  backtrace: true     # store backtrace in dead-letter set for debugging

  # Sidekiq retry callbacks (Sidekiq 7+):
  sidekiq_retries_exhausted do |msg, ex|
    Sentry.capture_exception(ex, extra: { job: msg })
    OrdersMailer.admin_notification_failed(msg["args"]).deliver_later
  end

  def perform(order_id, event_type)
    order = Order.find_by(id: order_id)
    return if order.nil?  # idempotency: job re-runs safely if order was deleted

    case event_type.to_sym
    when :created  then OrderMailer.created(order).deliver_now
    when :shipped  then OrderMailer.shipped(order).deliver_now
    when :refunded then OrderMailer.refunded(order).deliver_now
    end
  end
end

# Queue topology configuration (config/sidekiq.yml):
# :queues:
#   - [critical, 3]   # weight 3 — dequeued 3× more often than weight-1 queues
#   - [default, 2]
#   - [low, 1]
# :concurrency: 10

# Calling the job:
Orders::NotifyCustomerJob.perform_async(order.id, :created)
Orders::NotifyCustomerJob.perform_in(5.minutes, order.id, :created)  # delayed
Orders::NotifyCustomerJob.perform_at(Time.zone.now + 1.hour, order.id, :created)

# Sidekiq batch (Sidekiq Pro) — run a callback when all jobs in a batch complete:
batch = Sidekiq::Batch.new
batch.description = "Process order batch #{batch_id}"
batch.on(:success, OrderBatchCallbackJob, batch_id: batch_id)
batch.on(:death, OrderBatchErrorJob, batch_id: batch_id)

batch.jobs do
  order_ids.each { |id| Orders::ProcessJob.perform_async(id) }
end

# Monitoring Sidekiq queues programmatically:
stats  = Sidekiq::Stats.new
queue  = Sidekiq::Queue.new("critical")
dead   = Sidekiq::DeadSet.new
retry_ = Sidekiq::RetrySet.new

puts "Enqueued: #{stats.enqueued}"
puts "Critical queue depth: #{queue.size}"
puts "Dead jobs: #{dead.size}"
puts "Retry jobs: #{retry_.size}"

# Clear dead set (after investigation):
dead.clear

# Replay all jobs in retry set immediately:
retry_.each(&:retry)

ActionMailer and ActionJob configuration

# config/environments/production.rb — wire ActionMailer to Sidekiq via ActionJob:
config.active_job.queue_adapter = :sidekiq

# ActionMailer — deliver_later enqueues via ActionJob → Sidekiq:
class OrderMailer < ApplicationMailer
  default from: "billing@example.com"

  def created(order)
    @order    = order
    @customer = order.customer

    attachments["invoice.pdf"] = {
      mime_type: "application/pdf",
      content:   PDFGenerator.invoice(@order)
    }

    mail(
      to:      @customer.email,
      subject: "Order ##{@order.number} confirmed"
    )
  end

  # deliver_later with queue and priority:
  def shipped(order)
    @order = order
    mail(
      to:      order.customer.email,
      subject: "Your order ##{order.number} has shipped"
    ).deliver_later(queue: "critical", wait: 1.minute)
  end
end

# Calling mailers:
OrderMailer.created(order).deliver_later          # enqueues in default queue
OrderMailer.created(order).deliver_later(
  queue: "critical",
  wait_until: Date.tomorrow.midnight
)
OrderMailer.created(order).deliver_now            # synchronous — avoid in controllers

# ActionJob base class configuration:
class ApplicationJob < ActiveJob::Base
  queue_as :default

  # Discard jobs for missing records (prevents repeated failure cycles):
  discard_on ActiveJob::DeserializationError

  # Retry on transient errors:
  retry_on Net::OpenTimeout, Faraday::TimeoutError, wait: :polynomially_longer, attempts: 5

  # Callback — log all job executions:
  before_perform do |job|
    Rails.logger.info("JOB START: #{job.class} #{job.job_id} args=#{job.arguments}")
  end

  after_perform do |job|
    Rails.logger.info("JOB DONE: #{job.class} #{job.job_id}")
  end
end

Ruby language depth

Modern Ruby (3.2+) provides pattern matching, immutable value objects, and improved concurrency primitives that reduce boilerplate and make intent explicit. A Ruby architect on retainer applies these features judiciously: replacing multi-branch conditional logic with pattern matching, using Data.define for result types, and understanding where Ractors and Fibers provide practical concurrency gains.

Pattern matching and Data.define

# Pattern matching (case/in) — Ruby 3.0+:
def process_event(event)
  case event
  in { type: "order_created", order: { id: Integer => id, status: "pending" } }
    Orders::NotifyCustomerJob.perform_async(id, :created)
  in { type: "order_shipped", order: { id: Integer => id }, tracking: String => code }
    Orders::UpdateTrackingJob.perform_async(id, code)
  in { type: "payment_failed", order: { id: Integer => id }, reason: String => reason }
    Orders::HandleFailedPaymentJob.perform_async(id, reason)
  in { type: String => type }
    Rails.logger.warn("Unhandled event type: #{type}")
  end
end

# Find pattern (Ruby 3.0+) — matches if the pattern is found anywhere in the object:
case [1, 2, 3, 4, 5]
in [*, 3, *]
  puts "3 found"
end

# Pin operator — match against a variable value (not bind a new variable):
expected_status = :pending
case order
in { status: ^expected_status }
  puts "Order is still pending"
end

# Data.define (Ruby 3.2+) — immutable value object (like Struct but frozen):
Result   = Data.define(:success, :value, :errors)
Point    = Data.define(:x, :y)
UserInfo = Data.define(:id, :name, :email)

result = Result.new(success: true, value: order, errors: [])
result.success   # => true
result.frozen?   # => true — immutable by default
result.with(errors: ["something"]) # => new instance with modified field

# Replacing OpenStruct (slow, not type-safe) and plain Hash (no method access):
# BEFORE: OpenStruct.new(success: true, order: order)
# AFTER:  ServiceResult.new(success: true, order: order, errors: [])

# method_missing and respond_to_missing? — implement dynamic delegation:
class ApiProxy
  def initialize(client)
    @client = client
  end

  def method_missing(name, *args, **kwargs, &block)
    if @client.respond_to?(name)
      @client.public_send(name, *args, **kwargs, &block)
    else
      super
    end
  end

  def respond_to_missing?(name, include_private = false)
    @client.respond_to?(name, include_private) || super
  end
end

# Endless method (Ruby 3.0+) — single-expression methods on one line:
def double(x) = x * 2
def greeting(name) = "Hello, #{name}!"

# Numbered block parameters (Ruby 2.7+):
[1, 2, 3].map { _1 * 2 }           # => [2, 4, 6]
{a: 1, b: 2}.map { [_1, _2 * 10] } # => [[:a, 10], [:b, 20]]

Refinements and frozen string literals

# Refinements — scoped monkey-patching that doesn't leak into other files:
module StringExtensions
  refine String do
    def to_slug
      downcase.gsub(/[^a-z0-9\s-]/, "").gsub(/\s+/, "-")
    end

    def truncate_words(count)
      words = split
      words.length > count ? words.first(count).join(" ") + "…" : self
    end
  end
end

# using — activates the refinement only in this file's scope:
class PostDecorator
  using StringExtensions

  def initialize(post)
    @post = post
  end

  def slug   = @post.title.to_slug        # refinement available here
  def teaser = @post.body.truncate_words(20)
end

PostDecorator.new(post).slug              # works — refinement active in this class
"hello world".to_slug                     # NoMethodError — refinement not active here

# frozen_string_literal: true — all string literals are frozen (immutable):
# Add to every file: # frozen_string_literal: true
# Reduces GC pressure (fewer String object allocations):
str = "hello"
str.frozen?   # => true — cannot be mutated with <<, replace, etc.
str = +"hello"  # + prefix creates a mutable string (rare, for in-place mutation)

# Symbol vs. String in hashes — symbols are frozen and deduped by default:
{ name: "Alice" }       # symbol key — faster hash lookup, less GC pressure
{ "name" => "Alice" }   # string key — each literal allocates a new String object
                        # unless frozen_string_literal: true

RSpec, FactoryBot, and testing patterns

A Rails architect on retainer governs the test suite architecture: the database cleaning strategy (transaction vs. truncation), FactoryBot factory design (trait hierarchies vs. flat factories), VCR cassette strategy for HTTP-dependent specs, and the use of Capybara for system tests. Getting these right reduces a 12-minute suite to 3 minutes without touching a single spec.

RSpec structure and DatabaseCleaner

# spec/rails_helper.rb — database cleaning strategy:
RSpec.configure do |config|
  config.use_transactional_fixtures = false  # disable Rails' built-in (DatabaseCleaner takes over)

  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction   # default: wrap each spec in a transaction (fast)
    DatabaseCleaner.clean_with(:truncation)   # initial clean before suite (wipe any leftover data)
  end

  # Switch to truncation for specs that require multiple database connections
  # (e.g., Capybara system tests with JS driver — browser runs in a separate thread):
  config.before(:each, :js) do
    DatabaseCleaner.strategy = :truncation
  end

  config.before(:each) { DatabaseCleaner.start }
  config.after(:each)  { DatabaseCleaner.clean }
end

# RSpec describe/context/it structure — precise naming for failure messages:
RSpec.describe Orders::CreateService do
  subject(:result) { described_class.new(params: params, user: user).call }

  let(:user)   { create(:user, :active) }
  let(:params) { attributes_for(:order, :with_line_items) }

  describe "#call" do
    context "when params are valid" do
      it "returns success" do
        expect(result.success).to be true
      end

      it "creates an order" do
        expect { result }.to change(Order, :count).by(1)
      end

      it "enqueues a notification job" do
        result
        expect(Orders::NotifyCustomerJob).to have_been_enqueued
          .with(kind_of(Integer), "created")
      end
    end

    context "when user is suspended" do
      let(:user) { create(:user, :suspended) }

      it "returns failure" do
        expect(result.success).to be false
      end

      it "does not create an order" do
        expect { result }.not_to change(Order, :count)
      end
    end

    context "when params are missing required fields" do
      let(:params) { {} }

      it "includes errors" do
        expect(result.errors).not_to be_empty
      end
    end
  end
end

FactoryBot and VCR

# spec/factories/orders.rb — lean factory with traits:
FactoryBot.define do
  factory :order do
    association :customer
    status      { :draft }
    currency    { "USD" }
    total       { Faker::Commerce.price(range: 10..500) }

    # Traits — compose instead of creating deeply nested factories:
    trait :pending do
      status { :pending }
    end

    trait :with_line_items do
      # transient — not persisted, used as input to after(:create):
      transient do
        items_count { 3 }
      end

      after(:create) do |order, evaluator|
        create_list(:line_item, evaluator.items_count, order: order)
        order.reload  # reload to pick up counter caches and calculated totals
      end
    end

    trait :high_value do
      total { Faker::Commerce.price(range: 1000..10_000) }
    end
  end
end

# Usage:
create(:order)                            # draft order
create(:order, :pending)                  # pending order
create(:order, :pending, :with_line_items, items_count: 5)
build_stubbed(:order, :high_value)        # in-memory stub (no DB write — fast)

# VCR — record and replay HTTP interactions:
# spec/spec_helper.rb:
VCR.configure do |c|
  c.cassette_library_dir = "spec/vcr_cassettes"
  c.hook_into :webmock
  c.configure_rspec_metadata!
  c.filter_sensitive_data("") { ENV.fetch("STRIPE_SECRET_KEY") }
  c.default_cassette_options = {
    record:            :none,     # :new_episodes for updating, :none for CI
    allow_unused_http_interactions: false  # fail if a cassette interaction is not used
  }
end

# Use in a spec:
describe "Stripe payment", :vcr do
  it "charges the card" do
    result = Stripe::Charge.create(amount: 1000, currency: "usd", source: "tok_visa")
    expect(result.status).to eq "succeeded"
  end
end
# First run: records HTTP to spec/vcr_cassettes/Stripe_payment/charges_the_card.yml
# Subsequent runs: replays from cassette — no network, no API key required

# WebMock — stub individual HTTP calls without cassettes:
before do
  stub_request(:post, "https://api.example.com/webhooks")
    .with(body: hash_including("event" => "order.created"))
    .to_return(status: 200, body: '{"received":true}', headers: { "Content-Type" => "application/json" })
end

Sorbet and RBS type coverage

Sorbet adds static type checking to Ruby codebases incrementally. A Rails architect on retainer introduces Sorbet in levels — starting at typed: false and progressing toward typed: strict on high-value modules — and writes RBI files for gems without native Sorbet support. The goal is not 100% coverage; it is preventing the specific class of runtime NoMethodError and ArgumentError bugs that Sorbet catches at check time.

# typed: true — Sorbet checks method signatures and type constraints:
# typed: strict — Sorbet requires signatures on every method

# typed: strict
require "sorbet-runtime"

class Orders::CreateService
  extend T::Sig

  Result = T.type_alias { T::Struct }

  class Result < T::Struct
    const :success, T::Boolean
    const :order,   T.nilable(Order)
    const :errors,  T::Array[String]
  end

  sig { params(params: ActionController::Parameters, user: User).void }
  def initialize(params:, user:)
    @params = T.let(params, ActionController::Parameters)
    @user   = T.let(user, User)
  end

  sig { returns(Result) }
  def call
    order = Order.new(@params.to_h)
    order.user = @user

    if order.save
      Result.new(success: true, order: order, errors: [])
    else
      Result.new(success: false, order: nil, errors: order.errors.full_messages)
    end
  end
end

# T.nilable — marks a value as potentially nil:
sig { params(id: Integer).returns(T.nilable(Order)) }
def find_order(id)
  Order.find_by(id: id)   # returns nil if not found
end

# T.any — union type:
sig { params(input: T.any(String, Integer)).returns(String) }
def format_id(input)
  input.to_s
end

# T::Enum — typed enum values:
class OrderStatus < T::Enum
  enums do
    Draft      = new("draft")
    Pending    = new("pending")
    Processing = new("processing")
    Shipped    = new("shipped")
  end
end

# srb typecheck — run static type checking:
# $ bundle exec srb typecheck
# Sorbet reports: method call on nil (T.nilable not handled), wrong argument count,
# wrong argument type, undefined method, missing return type.

# tapioca — auto-generate RBI files for gems:
# $ bundle exec tapioca gems        # generate RBIs for all gems
# $ bundle exec tapioca annotations  # download community annotations from sorbet-typed

Logging Ruby retainer hours so clients understand the work

Rails retainer work is invisible in the same way that all platform engineering is invisible: an ActiveRecord N+1 elimination that reduces query count from 847 to 4 produces no new endpoint, no new screen, and no change visible to the product manager. A Sidekiq queue redesign that eliminates invoice delivery delays produces faster emails but leaves no trace in the feature changelog. A service object extraction that moves 300 lines of business logic out of a fat model produces cleaner code that no customer ever sees.

The work log entry is what connects the invisible Rails platform investment to its concrete business outcome. A well-written entry captures the advisory category (ActiveRecord N+1 elimination, Sidekiq queue architecture, service object extraction, RSpec suite optimization, Rails upgrade advisory, YJIT tuning, database index design, Bullet configuration), the specific controller or model being worked on, the task performed, the Bullet or EXPLAIN ANALYZE finding that revealed the problem, the specific API decisions made in the fix, and the before/after metric.

HourTab turns this structured work log into a public retainer URL that the client can bookmark — a live view of hours logged, progress against the monthly allocation, and the work summaries behind each entry. When the client asks “what has our Rails architect been doing this month?”, the HourTab URL answers with the Bullet log that identified the 847-query N+1 pattern, the includes strategy that fixed it, and the query count and response time before and after — without requiring a status call or a separately maintained report.

Retainer structure for Ruby on Rails developer engagements

A Ruby on Rails developer retainer typically covers four functional areas: feature development (new controllers and views, new ActiveRecord models and migrations, new Sidekiq jobs, new RSpec specs), Rails architecture advisory (service object design, callback auditing, routing design, Strong Parameters review, Pundit policy design), performance optimization (ActiveRecord N+1 elimination with Bullet, database index design, YJIT tuning, rack-mini-profiler analysis, database connection pool sizing), and test suite governance (RSpec suite optimization, DatabaseCleaner strategy, FactoryBot factory design, VCR cassette management, parallel_tests configuration). Each area should have its own hour allocation in the retainer agreement so that platform work is not competing with feature development for the same pool of hours.

Monthly retainer amounts for Ruby on Rails developer advisory and Rails architecture consulting typically range from $4,500 to $9,000 per month for architecture advisory retainers (15 to 30 hours per month at mid-to-senior rates of $115 to $210 per hour), increasing to $12,000 to $25,000 per month for full-platform Rails consulting engagements (30 to 60 hours per month) covering ActiveRecord optimization, Sidekiq queue design, service object architecture, RSpec suite performance, Ruby version upgrade management, and Sorbet type coverage. Senior Rails architects billing at $180 to $330 per hour typically structure retainers at 20 to 45 hours per month, covering one deep architecture engagement per week plus ongoing advisory and code review.

The retainer pays for itself when it prevents a single N+1 regression from reaching the scale tier that makes the database the bottleneck: a query that issues 4 database calls at 200 users and 4,000 calls at 200,000 users is the same code path. The architectural pattern that causes the regression — loading an association inside an each loop, calling order.customer.name without includes(:customer), rendering a collection partial without eager-loading its associations — takes 2 to 4 hours to diagnose and fix. Left unaddressed until it triggers a production incident, it consumes 20 to 50 hours of engineering time across database, Rails, and infrastructure teams in an emergency. Monthly retainer advisory prevents that accumulation before it becomes an incident.


HourTab is a public retainer dashboard for freelance Ruby on Rails developers and Rails consulting firms. Upload your time-tracker CSV and get a shareable URL your client can bookmark — a live view of hours logged, remaining allocation, and work log summaries. No client login, no portal. Try it free with one active retainer.