Ruby on Rails Developer
Expert in Ruby on Rails development with focus on Rails 8, Hotwire, and the Solid Trifecta stack.
Core Principles
stack_philosophy: framework: "Rails 8" database: "SQLite3 (production-ready)" background_jobs: "SolidQueue" websockets: "SolidCable" caching: "SolidCache" frontend: "Hotwire (Turbo + Stimulus)" styling: "Tailwind CSS"
code_conventions: naming: files: "snake_case" methods: "snake_case" classes: "CamelCase" constants: "SCREAMING_SNAKE_CASE"
structure: - "Follow Rails conventions" - "RESTful routing" - "Thin controllers, fat models" - "Service objects for complex logic"
Rails 8 Features
Authentication
Generate built-in authentication
rails g authentication
app/models/user.rb
class User < ApplicationRecord has_secure_password
normalizes :email, with: ->(email) { email.strip.downcase }
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } end
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController def create user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
session[:user_id] = user.id
redirect_to root_path, notice: "Logged in!"
else
flash.now[:alert] = "Invalid email or password"
render :new, status: :unprocessable_entity
end
end
def destroy session[:user_id] = nil redirect_to root_path, notice: "Logged out!" end end
Solid Trifecta
config/queue.yml - SolidQueue configuration
production: workers: - queues: "*" threads: 5 polling_interval: 0.1 dispatchers: - polling_interval: 1 batch_size: 500
app/jobs/process_order_job.rb
class ProcessOrderJob < ApplicationJob queue_as :default retry_on StandardError, wait: :polynomially_longer, attempts: 5
def perform(order_id) order = Order.find(order_id) OrderProcessor.new(order).process! end end
Using SolidCable for ActionCable
config/cable.yml
production: adapter: solid_cable polling_interval: 0.1
Using SolidCache
config/cache.yml
production: store: solid_cache size: 256.megabytes
ActiveRecord Patterns
Models & Validations
app/models/product.rb
class Product < ApplicationRecord belongs_to :category has_many :order_items, dependent: :restrict_with_error has_many :orders, through: :order_items has_one_attached :image has_rich_text :description
enum :status, { draft: 0, published: 1, archived: 2 }
validates :name, presence: true, length: { maximum: 255 } validates :price, presence: true, numericality: { greater_than: 0 } validates :sku, presence: true, uniqueness: true
scope :available, -> { published.where("stock > 0") } scope :featured, -> { where(featured: true).order(created_at: :desc) }
before_validation :generate_sku, on: :create after_commit :update_search_index, on: [:create, :update]
private
def generate_sku self.sku ||= "SKU-#{SecureRandom.hex(4).upcase}" end
def update_search_index SearchIndexJob.perform_later(self) end end
Query Interface
Efficient queries
class ProductQuery def initialize(relation = Product.all) @relation = relation end
def search(term) return self if term.blank? @relation = @relation.where("name ILIKE :term OR description ILIKE :term", term: "%#{term}%") self end
def by_category(category_id) return self if category_id.blank? @relation = @relation.where(category_id: category_id) self end
def price_range(min:, max:) @relation = @relation.where(price: min..max) if min && max self end
def results @relation end end
Usage
products = ProductQuery.new .search(params[:q]) .by_category(params[:category_id]) .price_range(min: 10, max: 100) .results .includes(:category, image_attachment: :blob) .page(params[:page])
Migrations
db/migrate/20241201000000_create_orders.rb
class CreateOrders < ActiveRecord::Migration[8.0] def change create_table :orders do |t| t.references :user, null: false, foreign_key: true t.string :number, null: false, index: { unique: true } t.integer :status, null: false, default: 0 t.decimal :total, precision: 10, scale: 2, null: false, default: 0 t.jsonb :metadata, null: false, default: {} t.datetime :completed_at
t.timestamps
end
add_index :orders, :status
add_index :orders, :completed_at
add_index :orders, [:user_id, :status]
end end
Hotwire
Turbo Frames
<!-- app/views/products/index.html.erb --> <%= turbo_frame_tag "products" do %> <div class="grid grid-cols-3 gap-4"> <% @products.each do |product| %> <%= render product %> <% end %> </div>
<%= paginate @products %> <% end %>
<!-- app/views/products/_product.html.erb --> <%= turbo_frame_tag dom_id(product) do %> <div class="card" data-controller="product"> <h3><%= product.name %></h3> <p><%= number_to_currency product.price %></p>
<%= link_to "Edit", edit_product_path(product),
data: { turbo_frame: "modal" } %>
</div> <% end %>
Turbo Streams
app/controllers/comments_controller.rb
class CommentsController < ApplicationController def create @comment = @post.comments.build(comment_params) @comment.user = current_user
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @post }
end
else
render :new, status: :unprocessable_entity
end
end end
app/views/comments/create.turbo_stream.erb
<%= turbo_stream.prepend "comments", @comment %> <%= turbo_stream.update "new_comment" do %> <%= render "form", comment: Comment.new %> <% end %> <%= turbo_stream.update "comments_count", @post.comments.count %>
Stimulus Controllers
// app/javascript/controllers/form_controller.js import { Controller } from "@hotwired/stimulus"
export default class extends Controller { static targets = ["input", "submit", "output"] static values = { url: String, debounce: { type: Number, default: 300 } }
connect() { this.timeout = null }
search() { clearTimeout(this.timeout) this.timeout = setTimeout(() => { this.performSearch() }, this.debounceValue) }
async performSearch() { const query = this.inputTarget.value if (query.length < 2) return
this.submitTarget.disabled = true
try {
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(query)}`)
const html = await response.text()
this.outputTarget.innerHTML = html
} finally {
this.submitTarget.disabled = false
}
} }
Testing with RSpec
spec/models/product_spec.rb
RSpec.describe Product, type: :model do describe "validations" do subject { build(:product) }
it { should validate_presence_of(:name) }
it { should validate_presence_of(:price) }
it { should validate_uniqueness_of(:sku) }
it { should validate_numericality_of(:price).is_greater_than(0) }
end
describe "associations" do it { should belong_to(:category) } it { should have_many(:order_items) } end
describe "scopes" do describe ".available" do let!(:available) { create(:product, status: :published, stock: 10) } let!(:out_of_stock) { create(:product, status: :published, stock: 0) } let!(:draft) { create(:product, status: :draft, stock: 10) }
it "returns only published products with stock" do
expect(Product.available).to contain_exactly(available)
end
end
end end
spec/requests/products_spec.rb
RSpec.describe "Products", type: :request do describe "GET /products" do it "returns successful response" do get products_path expect(response).to have_http_status(:success) end end
describe "POST /products" do let(:valid_params) { { product: attributes_for(:product) } }
context "with valid params" do
it "creates a new product" do
expect {
post products_path, params: valid_params
}.to change(Product, :count).by(1)
end
end
end end
spec/system/products_spec.rb
RSpec.describe "Product management", type: :system do before { driven_by(:selenium_chrome_headless) }
it "allows creating a new product" do visit new_product_path
fill_in "Name", with: "Test Product"
fill_in "Price", with: "99.99"
click_button "Create Product"
expect(page).to have_content("Product was successfully created")
expect(page).to have_content("Test Product")
end end
Performance Optimization
Caching
Fragment caching with Russian Doll strategy
app/views/products/_product.html.erb
<% cache product do %> <div class="product"> <% cache [product, "details"] do %> <h3><%= product.name %></h3> <p><%= product.description %></p> <% end %>
<% cache [product.category, "category"] do %>
<span class="category"><%= product.category.name %></span>
<% end %>
</div> <% end %>
Low-level caching
class Product < ApplicationRecord def expensive_calculation Rails.cache.fetch([cache_key_with_version, "calculation"], expires_in: 1.hour) do # Complex computation perform_expensive_operation end end end
Counter caching
class Comment < ApplicationRecord belongs_to :post, counter_cache: true end
N+1 Prevention
app/controllers/posts_controller.rb
class PostsController < ApplicationController def index @posts = Post .includes(:author, :comments, :tags) .with_attached_image .order(created_at: :desc) .page(params[:page]) end end
Using strict loading in development
config/environments/development.rb
config.active_record.strict_loading_by_default = true
Database Optimization
Add proper indexes
class AddIndexesToProducts < ActiveRecord::Migration[8.0] def change add_index :products, :category_id add_index :products, [:status, :created_at] add_index :products, :price
# Partial index
add_index :products, :featured, where: "featured = true"
# GIN index for JSONB
add_index :products, :metadata, using: :gin
end end
Bulk operations
Product.insert_all([ { name: "Product 1", price: 10 }, { name: "Product 2", price: 20 } ])
Product.where(status: :draft).update_all(status: :archived)
Security
Strong parameters
class ProductsController < ApplicationController private
def product_params params.require(:product).permit(:name, :price, :description, :category_id, :image, tags: []) end end
Authorization
class ApplicationController < ActionController::Base before_action :authenticate_user!
private
def authorize_admin! redirect_to root_path, alert: "Not authorized" unless current_user.admin? end end
Content Security Policy
config/initializers/content_security_policy.rb
Rails.application.configure do config.content_security_policy do |policy| policy.default_src :self, :https policy.font_src :self, :https, :data policy.img_src :self, :https, :data policy.object_src :none policy.script_src :self, :https policy.style_src :self, :https, :unsafe_inline end end
API Mode
app/controllers/api/v1/base_controller.rb
module Api module V1 class BaseController < ActionController::API include ActionController::HttpAuthentication::Token::ControllerMethods
before_action :authenticate_api_user!
private
def authenticate_api_user!
authenticate_or_request_with_http_token do |token, options|
@current_api_user = User.find_by(api_token: token)
end
end
def current_api_user
@current_api_user
end
end
end end
app/controllers/api/v1/products_controller.rb
module Api module V1 class ProductsController < BaseController def index products = Product.available.page(params[:page]).per(25)
render json: {
products: products.as_json(only: [:id, :name, :price]),
meta: {
current_page: products.current_page,
total_pages: products.total_pages,
total_count: products.total_count
}
}
end
def show
product = Product.find(params[:id])
render json: ProductSerializer.new(product).as_json
end
end
end end
Лучшие практики
-
Convention over Configuration — следуй Rails conventions
-
Fat Model, Skinny Controller — логика в моделях и сервисах
-
Hotwire first — минимум JavaScript, максимум Turbo
-
Test everything — RSpec для моделей, запросов и системных тестов
-
Cache strategically — Russian Doll caching для производительности
-
Secure by default — strong parameters, CSP, аутентификация