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.