Website Backend Development with Ruby (Ruby on Rails)
Rails remains one of the most productive frameworks for teams needing to launch a product quickly without building every layer from scratch. Conventions over configuration isn't marketing here — it literally describes how code generation, routing, ORM, and testing work.
Where Rails shines
Content platforms, marketplaces, multi-tenant SaaS, admin panels — wherever speed of iteration matters more than raw performance. Shopify, GitHub, Basecamp — all run on Rails under massive loads. With Rails 7 + Puma + Falcon or Unicorn, 3–5k RPS per instance is normal for properly written applications.
Modern stack
Rails 7.1 + Hotwire (Turbo + Stimulus) + PostgreSQL — a stack that doesn't require a separate React frontend for most tasks. For API-only mode:
# config/application.rb
module MyApp
class Application < Rails::Application
config.api_only = true
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore
end
end
Active Record and migrations
# db/migrate/20240315_create_orders.rb
class CreateOrders < ActiveRecord::Migration[7.1]
def change
create_table :orders do |t|
t.references :user, null: false, foreign_key: true
t.integer :status, null: false, default: 0
t.decimal :total, precision: 10, scale: 2, null: false
t.jsonb :metadata, default: {}
t.timestamps
end
add_index :orders, :status
add_index :orders, :created_at
add_index :orders, [:user_id, :status]
end
end
# app/models/order.rb
class Order < ApplicationRecord
belongs_to :user
has_many :items, class_name: 'OrderItem', dependent: :destroy
enum :status, { pending: 0, paid: 1, shipped: 2, delivered: 3, cancelled: 4 }
scope :recent, -> { order(created_at: :desc) }
scope :for_period, ->(from, to) { where(created_at: from..to) }
validates :total, numericality: { greater_than: 0 }
after_update_commit :broadcast_status_change, if: :saved_change_to_status?
private
def broadcast_status_change
ActionCable.server.broadcast("order_#{id}", { status: status })
end
end
Service objects
For business logic that doesn't fit in a model:
# app/services/orders/create_service.rb
module Orders
class CreateService
Result = Data.define(:success, :order, :errors)
def initialize(user:, params:)
@user = user
@params = params
end
def call
ActiveRecord::Base.transaction do
order = @user.orders.build(status: :pending)
items = build_items(order)
order.total = items.sum { |i| i.price * i.quantity }
order.save!
order.items << items
PaymentJob.perform_later(order.id)
Result.new(success: true, order: order, errors: [])
end
rescue ActiveRecord::RecordInvalid => e
Result.new(success: false, order: nil, errors: e.record.errors.full_messages)
end
private
def build_items(order)
@params[:items].map do |item_params|
product = Product.find(item_params[:product_id])
OrderItem.new(
order: order,
product: product,
price: product.current_price,
quantity: item_params[:quantity]
)
end
end
end
end
Controller and serialization
# app/controllers/api/v1/orders_controller.rb
module Api
module V1
class OrdersController < ApplicationController
before_action :authenticate_user!
def index
orders = current_user.orders.recent.page(params[:page]).per(25)
render json: OrderSerializer.new(orders, { meta: pagination_meta(orders) })
end
def create
result = Orders::CreateService.new(
user: current_user,
params: order_params
).call
if result.success
render json: OrderSerializer.new(result.order), status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
private
def order_params
params.require(:order).permit(items: [:product_id, :quantity])
end
end
end
end
Serialization via jsonapi-serializer:
class OrderSerializer
include JSONAPI::Serializer
attributes :status, :total, :created_at
has_many :items, serializer: OrderItemSerializer
belongs_to :user, serializer: UserSerializer
end
Background jobs with Sidekiq
# app/jobs/payment_job.rb
class PaymentJob < ApplicationJob
queue_as :payments
sidekiq_options retry: 3, backtrace: 5
def perform(order_id)
order = Order.find(order_id)
return if order.paid?
result = Payments::StripeService.new(order).charge
if result.success?
order.paid!
else
order.cancelled!
raise PaymentFailedError, result.error_message
end
end
end
# config/sidekiq.yml
concurrency: 10
queues:
- [payments, 3]
- [mailers, 2]
- [default, 1]
Caching
# Russian-cache via Redis
def cached_categories
Rails.cache.fetch("categories/all", expires_in: 1.hour) do
Category.active.includes(:children).to_a
end
end
# Fragment caching in API
def index
categories = Rails.cache.fetch_multi(*Category.active.pluck(:id).map { "category/#{_1}" }) do |key|
id = key.split('/').last.to_i
Category.find(id)
end
render json: categories.values
end
Testing
# spec/services/orders/create_service_spec.rb
RSpec.describe Orders::CreateService do
let(:user) { create(:user) }
let(:product) { create(:product, price: 99.99) }
describe '#call' do
subject(:result) do
described_class.new(user: user, params: { items: [{ product_id: product.id, quantity: 2 }] }).call
end
it 'creates order with correct total' do
expect(result.success).to be true
expect(result.order.total).to eq(199.98)
end
it 'enqueues payment job' do
expect { result }.to have_enqueued_job(PaymentJob)
end
end
end
Deployment
Puma in cluster mode + Nginx as reverse proxy is standard. Kamal (from Basecamp) simplifies Docker deployment without Kubernetes:
# config/deploy.yml (Kamal)
service: myapp
image: registry.example.com/myapp
servers:
web:
hosts: [10.0.0.1, 10.0.0.2]
options:
memory: 512m
workers:
hosts: [10.0.0.3]
cmd: bundle exec sidekiq
env:
secret: [RAILS_MASTER_KEY, DATABASE_URL, REDIS_URL]
Timeline
API for mobile app (auth, 10–15 resources, Sidekiq): 1–2 weeks. Full SaaS backend with multi-tenancy, subscriptions, webhooks and advanced logic: 4–6 weeks. Refactoring Rails 4/5 to 7.1 with gem updates — typically 2–3 weeks for audit and patching.







