Ruby on Rails has been a go-to framework for web development since its inception. Its convention over configuration philosophy and elegant syntax have made it a favorite among developers. However, as projects grow in complexity, maintaining clean and efficient code becomes crucial. In this article, I’ll share eight best practices that have proven invaluable in my experience with Ruby on Rails.
- Embrace the DRY Principle
Don’t Repeat Yourself (DRY) is a fundamental principle in software development, and it’s particularly emphasized in Rails. The idea is simple: every piece of knowledge or logic should have a single, unambiguous representation within a system. In Rails, we have several tools at our disposal to achieve this.
One of the most powerful ways to implement DRY in Rails is through the use of concerns. Concerns allow us to extract common functionality into modules that can be shared across multiple models or controllers. Here’s an example:
module Searchable
extend ActiveSupport::Concern
included do
scope :search, ->(query) { where("name LIKE ?", "%#{query}%") }
end
def highlight_matches(query)
name.gsub(/(#{query})/i, '<strong>\1</strong>')
end
end
class Product < ApplicationRecord
include Searchable
end
class Category < ApplicationRecord
include Searchable
end
In this example, we’ve created a Searchable concern that provides a search scope and a highlight_matches method. By including this concern in both Product and Category models, we avoid duplicating this functionality.
- Follow SOLID Principles
SOLID is an acronym for five design principles that help make software designs more understandable, flexible, and maintainable. While these principles were originally conceived for object-oriented programming, they apply well to Rails development.
Let’s focus on the Single Responsibility Principle (SRP) as an example. This principle states that a class should have only one reason to change. In Rails, we often see this violated in models that become bloated with various responsibilities.
Consider a User model that handles authentication, profile management, and order processing:
class User < ApplicationRecord
has_secure_password
has_many :orders
def update_profile(params)
# Update user profile
end
def place_order(product)
# Place an order
end
def calculate_total_spent
# Calculate total spent on orders
end
end
This User model is doing too much. We can improve it by extracting responsibilities into separate classes:
class User < ApplicationRecord
has_secure_password
has_many :orders
end
class ProfileManager
def initialize(user)
@user = user
end
def update(params)
# Update user profile
end
end
class OrderProcessor
def initialize(user)
@user = user
end
def place_order(product)
# Place an order
end
end
class SpendingCalculator
def initialize(user)
@user = user
end
def total_spent
# Calculate total spent on orders
end
end
By breaking down the responsibilities into separate classes, we’ve made our code more modular and easier to maintain.
- Keep Controllers Skinny
Controllers in Rails should act as a thin layer between the HTTP request and your application’s business logic. They should primarily be responsible for handling request parameters, invoking the appropriate business logic, and preparing data for the view.
A common anti-pattern is to stuff business logic directly into controllers. Instead, we should aim to move this logic into models or service objects. Here’s an example of a controller that’s doing too much:
class OrdersController < ApplicationController
def create
@order = Order.new(order_params)
if @order.save
if @order.total > 100
ApplyDiscount.new(@order).call
end
OrderMailer.confirmation(@order).deliver_now
redirect_to @order, notice: 'Order was successfully created.'
else
render :new
end
end
private
def order_params
params.require(:order).permit(:user_id, :total)
end
end
We can improve this by moving the business logic into a service object:
class OrdersController < ApplicationController
def create
result = CreateOrder.new(order_params).call
if result.success?
redirect_to result.order, notice: 'Order was successfully created.'
else
@order = result.order
render :new
end
end
private
def order_params
params.require(:order).permit(:user_id, :total)
end
end
class CreateOrder
def initialize(params)
@params = params
end
def call
order = Order.new(@params)
if order.save
apply_discount(order) if order.total > 100
send_confirmation_email(order)
OpenStruct.new(success?: true, order: order)
else
OpenStruct.new(success?: false, order: order)
end
end
private
def apply_discount(order)
ApplyDiscount.new(order).call
end
def send_confirmation_email(order)
OrderMailer.confirmation(order).deliver_now
end
end
This approach keeps our controller skinny and moves the business logic into a dedicated service object.
- Leverage Rails Conventions
Rails is opinionated, and that’s a good thing. By following Rails conventions, we write code that’s more consistent and easier for other Rails developers to understand. Here are a few key conventions to keep in mind:
- Use plural names for controllers (e.g., ProductsController)
- Use singular names for models (e.g., Product)
- Follow the standard RESTful actions in controllers (index, show, new, create, edit, update, destroy)
- Use snake_case for file names and method names
- Use CamelCase for class and module names
Here’s an example of a controller following these conventions:
class ProductsController < ApplicationController
def index
@products = Product.all
end
def show
@product = Product.find(params[:id])
end
def new
@product = Product.new
end
def create
@product = Product.new(product_params)
if @product.save
redirect_to @product, notice: 'Product was successfully created.'
else
render :new
end
end
private
def product_params
params.require(:product).permit(:name, :price)
end
end
- Use Service Objects for Complex Business Logic
As our application grows, we often encounter complex business logic that doesn’t fit neatly into models or controllers. This is where service objects come in handy. Service objects encapsulate a single piece of business logic, making our code more modular and easier to test.
Here’s an example of a service object that handles the process of placing an order:
class PlaceOrder
def initialize(user, product)
@user = user
@product = product
end
def call
ActiveRecord::Base.transaction do
create_order
update_inventory
send_confirmation_email
end
@order
end
private
def create_order
@order = @user.orders.create!(product: @product, total: @product.price)
end
def update_inventory
@product.decrement!(:stock_count)
end
def send_confirmation_email
OrderMailer.confirmation(@order).deliver_later
end
end
We can then use this service object in our controller:
class OrdersController < ApplicationController
def create
@order = PlaceOrder.new(current_user, Product.find(params[:product_id])).call
redirect_to @order, notice: 'Order was successfully placed.'
rescue ActiveRecord::RecordInvalid
redirect_to products_path, alert: 'Unable to place order.'
end
end
This approach keeps our controller clean and makes our business logic easy to understand and test.
- Write Comprehensive Tests
Testing is crucial for maintaining a healthy Rails application. A comprehensive test suite gives us confidence when refactoring or adding new features. In Rails, we have several types of tests at our disposal:
- Unit tests for models and other Ruby classes
- Controller tests for testing HTTP responses
- Integration tests for testing user flows
- System tests for testing the application from the user’s perspective
Here’s an example of a model test using RSpec:
RSpec.describe Product, type: :model do
it "is valid with valid attributes" do
product = Product.new(name: "Test Product", price: 9.99)
expect(product).to be_valid
end
it "is not valid without a name" do
product = Product.new(price: 9.99)
expect(product).to_not be_valid
end
it "is not valid without a price" do
product = Product.new(name: "Test Product")
expect(product).to_not be_valid
end
it "is not valid with a negative price" do
product = Product.new(name: "Test Product", price: -9.99)
expect(product).to_not be_valid
end
end
And here’s an example of a controller test:
RSpec.describe ProductsController, type: :controller do
describe "GET #index" do
it "returns a success response" do
get :index
expect(response).to be_successful
end
it "assigns all products as @products" do
product = Product.create!(name: "Test Product", price: 9.99)
get :index
expect(assigns(:products)).to eq([product])
end
end
describe "POST #create" do
context "with valid params" do
it "creates a new Product" do
expect {
post :create, params: {product: {name: "New Product", price: 19.99}}
}.to change(Product, :count).by(1)
end
it "redirects to the created product" do
post :create, params: {product: {name: "New Product", price: 19.99}}
expect(response).to redirect_to(Product.last)
end
end
context "with invalid params" do
it "returns a success response (i.e. to display the 'new' template)" do
post :create, params: {product: {name: "Invalid Product"}}
expect(response).to be_successful
end
end
end
end
- Use Scopes and Class Methods Effectively
ActiveRecord provides powerful tools for querying the database. Two of these tools are scopes and class methods. While they can often be used interchangeably, understanding their differences can help us write more expressive and efficient queries.
Scopes are a way to define commonly-used queries as method calls on the model. They always return an ActiveRecord::Relation, which allows them to be chained with other scopes or methods.
class Product < ApplicationRecord
scope :in_stock, -> { where("stock_count > 0") }
scope :price_above, ->(price) { where("price > ?", price) }
end
# Usage
Product.in_stock.price_above(100)
Class methods, on the other hand, can be more flexible. They can return anything, not just an ActiveRecord::Relation. They’re useful when you need to do something more complex than a simple query.
class Product < ApplicationRecord
def self.top_sellers(limit = 5)
select("products.*, SUM(order_items.quantity) as total_sold")
.joins(:order_items)
.group("products.id")
.order("total_sold DESC")
.limit(limit)
end
end
# Usage
Product.top_sellers(10)
In general, use scopes for simple queries that return a relation, and use class methods for more complex operations or when you need to return something other than a relation.
- Optimize Database Queries
As our application grows, database performance often becomes a bottleneck. Rails provides several tools to help us optimize our database queries. Here are a few techniques:
- Use includes to avoid N+1 queries:
# Bad (N+1 query)
@orders = Order.all
@orders.each do |order|
puts order.user.name
end
# Good
@orders = Order.includes(:user).all
@orders.each do |order|
puts order.user.name
end
- Use pluck when you only need specific columns:
# Instead of
User.all.map(&:email)
# Use
User.pluck(:email)
- Use find_each for batching when dealing with large datasets:
User.find_each do |user|
NewsMailer.weekly(user).deliver_now
end
- Use counter_cache to avoid counting associated records:
class Product < ApplicationRecord
has_many :reviews, counter_cache: true
end
class Review < ApplicationRecord
belongs_to :product, counter_cache: true
end
# Now you can use product.reviews_count instead of product.reviews.count
These practices have served me well in my journey with Ruby on Rails. They’ve helped me write cleaner, more maintainable code, and build more robust applications. Remember, these are guidelines, not strict rules. Always consider your specific context and requirements when applying these practices.
Writing clean and maintainable code is an ongoing process. It requires constant learning, refactoring, and improvement. But by following these best practices, we can create Rails applications that are a joy to work with and easy to maintain over time.
As you continue your Rails journey, keep exploring new techniques and best practices. The Rails community is constantly evolving and coming up with new ways to write better code. Stay curious, keep learning, and happy coding!