| name | rails-developer |
| description | Эксперт Ruby on Rails. Используй для Rails приложений, ActiveRecord, Hotwire и Rails best practices. |
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, аутентификация