Rails Architecture Best Practices and Building Scalable Applications — Ruby Deep Dive[11]
Let’s dive deeper in architecture best practices and building scalable applications in rails.
Moving Beyond MVC: When and How to Refactor
Identifying the Need for Refactoring
- Fat Models/Controllers: When your models or controllers become too large and difficult to maintain.
- Code Duplication: Repetitive code across the application.
- Complex Business Logic: Business logic that doesn’t naturally fit into models or controllers.
Refactoring Strategies
- Service Objects
- Query Objects
- Form Objects
- Presenters/Decorators
- Value Objects
- Concerns
Service Objects: Encapsulating Complex Business Logic
Service Objects encapsulate complex business logic outside of models and controllers.
Example
# app/services/create_user_service.rb
class CreateUserService
def initialize(user_params)
@user_params = user_params
end
def call
User.create(@user_params)
end
end
# Usage in controller
class UsersController < ApplicationController
def create
@user = CreateUserService.new(user_params).call
if @user.persisted?
redirect_to @user, notice: 'User created successfully.'
else
render :new
end
end
private
def user_params
params.require(:user).permit(:name, :email)
end
end
Query Objects: Keeping Database Queries Clean and Reusable
Query Objects encapsulate database queries, making them reusable and easier to test.
Example
# app/queries/recent_users_query.rb
class RecentUsersQuery
def initialize(relation = User.all)
@relation = relation
end
def call
@relation.where('created_at >= ?', 1.week.ago)
end
end
# Usage
recent_users = RecentUsersQuery.new.call
Form Objects: Handling Complex Forms and Validations
Form Objects help manage complex forms with validations and processing logic.
Example
# app/forms/user_registration_form.rb
class UserRegistrationForm
include ActiveModel::Model
attr_accessor :name, :email, :password
validates :name, :email, :password, presence: true
def save
return false unless valid?
user = User.create(name: name, email: email, password: password)
user.persisted?
end
end
# Usage in controller
class RegistrationsController < ApplicationController
def create
@form = UserRegistrationForm.new(user_params)
if @form.save
redirect_to root_path, notice: 'Registered successfully.'
else
render :new
end
end
private
def user_params
params.require(:user_registration_form).permit(:name, :email, :password)
end
end
Presenter/Decorator Pattern: Keeping Views Clean
Presenters or Decorators add presentation logic to models, keeping views clean.
Example with Draper Gem
# Gemfile
gem 'draper'
# app/decorators/user_decorator.rb
class UserDecorator < Draper::Decorator
delegate_all
def full_name
"#{object.first_name} #{object.last_name}"
end
end
# Usage in views
<%= @user.decorate.full_name %>
Value Objects: Encapsulating Domain Concepts
Value Objects represent domain concepts that have no identity beyond their attributes.
Example
# app/models/money.rb
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency)
@amount = amount
@currency = currency
end
def <=>(other)
amount <=> other.amount
end
def to_s
"#{amount} #{currency}"
end
end
# Usage
price = Money.new(100, 'USD')
puts price.to_s # "100 USD"
Concerns: When to Use (and When to Avoid)
Concerns modularize shared functionality, but overuse can lead to spaghetti code.
Example
# app/models/concerns/timestampable.rb
module Timestampable
extend ActiveSupport::Concern
included do
before_create :set_created_at
before_update :set_updated_at
end
private
def set_created_at
self.created_at ||= Time.current
end
def set_updated_at
self.updated_at = Time.current
end
end
# app/models/user.rb
class User < ApplicationRecord
include Timestampable
end
Event-Driven Architecture in Rails Applications
Event-driven architecture decouples components and makes the system more scalable.
Example with rails_event_store
Gem
# Gemfile
gem 'rails_event_store'
# app/events/user_created_event.rb
class UserCreatedEvent < RailsEventStore::Event
def self.create(user)
new(data: { user_id: user.id, name: user.name })
end
end
# Triggering an event
Rails.configuration.event_store.publish(UserCreatedEvent.create(user))
# Handling an event
class NotifyAdminOnUserCreated
def call(event)
AdminMailer.new_user(event.data[:user_id]).deliver_later
end
end
Rails.configuration.event_store.subscribe(
NotifyAdminOnUserCreated.new,
to: [UserCreatedEvent]
)
Building Scalable and Maintainable Rails Applications
Designing for Modularity with Rails Engines
Rails Engines encapsulate functionality into isolated modules, enhancing modularity.
Example
# Generate a new engine
rails plugin new blorgh --mountable
# Mounting the engine
# config/routes.rb
mount Blorgh::Engine, at: "/blorgh"
# Engine's routes
# blorgh/config/routes.rb
Blorgh::Engine.routes.draw do
resources :articles
end
API Versioning Strategies
Versioning ensures backward compatibility for API changes.
Example
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ApplicationController
# Version 1 API logic
end
end
end
# app/controllers/api/v2/base_controller.rb
module Api
module V2
class BaseController < ApplicationController
# Version 2 API logic
end
end
end
# config/routes.rb
namespace :api do
namespace :v1 do
resources :users
end
namespace :v2 do
resources :users
end
end
Handling Background Jobs Effectively (Sidekiq Best Practices)
Sidekiq Configuration
# Gemfile
gem 'sidekiq'
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.redis = { url: 'redis://localhost:6379/0' }
end
Sidekiq.configure_client do |config|
config.redis = { url: 'redis://localhost:6379/0' }
end
Defining a Worker
# app/workers/my_worker.rb
class MyWorker
include Sidekiq::Worker
def perform(user_id)
user = User.find(user_id)
user.update(last_login: Time.current)
end
end
Scheduling Jobs
# app/jobs/daily_summary_job.rb
class DailySummaryJob < ApplicationJob
queue_as :default
def perform
User.send_daily_summaries
end
end
# Schedule with `whenever`
every :day, at: '12:00 am' do
runner "DailySummaryJob.perform_later"
end
Strategies for Breaking Monoliths into Microservices
Identifying Boundaries
- Domain-Driven Design: Identify bounded contexts within your application.
Creating Microservices
- Separate Codebases: Each microservice should have its own repository.
- API Communication: Use HTTP/REST or gRPC for communication between services.
Example
# User Service (Microservice)
class UsersController < ApplicationController
def show
user = User.find(params[:id])
render json: user
end
end
# Order Service (Microservice)
class OrdersController < ApplicationController
def create
user_response = HTTP.get("http://user_service/users/#{params[:user_id]}")
user = JSON.parse(user_response.body)
# Create order logic
end
end
Feature Toggles and Canary Releases
Feature Toggles
Feature toggles allow you to enable or disable features without deploying new code.
Example with flipper
Gem
# Gemfile
gem 'flipper'
# config/initializers/flipper.rb
Flipper.configure do |config|
config.default do
adapter = Flipper::Adapters::ActiveRecord.new
Flipper.new(adapter)
end
end
# Usage
if Flipper.enabled?(:new_feature)
# New feature logic
else
# Old feature logic
end
Canary Releases
Canary releases allow you to deploy features to a small subset of users before a full rollout.
Example
# config/initializers/canary_releases.rb
class CanaryRelease
def self.enabled_for?(user)
user.id % 10 == 0 # Enable for 10% of users
end
end
# Usage
if CanaryRelease.enabled_for?(current_user)
# New feature logic
else
# Old feature logic
end
By implementing these best practices and strategies, you can build scalable, maintainable, and high-performance Rails applications. Keep refactoring your architecture, modularizing your codebase, and leveraging advanced features of Rails to stay ahead in the rapidly evolving landscape of web development.