Rails API Learning Roadmap
rails new myapp --api
What is an API?
🧠 Detailed Explanation
An API (Application Programming Interface) is like a menu in a restaurant. The menu shows you what food you can order, just like an API shows you what actions you can ask a program or app to do.
For example, if you’re using a mobile app to check the weather, the app uses an API to ask the weather server: “What’s the weather today in Lahore?” — and the server sends back the answer like a waiter delivering your order.
In Rails, an API helps the frontend (like a website or app) talk to the backend (the server).
You create API routes that return data in JSON
format.
🔁 API = Communication Bridge
It helps two different systems understand each other — just like a translator.
For example:
GET /api/v1/users
↓
Returns: [{ "id": 1, "name": "Ali" }, ...]
So when your frontend wants user data, it doesn’t go inside the database directly — it uses an API request like the example above.
🧩 Think of APIs like puzzle pieces — they fit different parts of your app together smoothly.
💡 Examples
Example 1: A website shows user profile using an API
Let’s say your frontend (React or mobile app) wants to show a user profile. Instead of directly accessing the database, it sends a request to an API:
GET /api/v1/users/1
This means: “Hey server, give me the details of user with ID 1.”
The API responds with JSON data:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com"
}
The frontend uses this data to display a profile page.
Example 2: Creating a new blog post
The frontend sends a request like:
POST /api/v1/posts
{
"title": "What is an API?",
"content": "An API lets software talk to each other."
}
The Rails server creates the post and sends back:
{
"id": 101,
"title": "What is an API?",
"status": "created"
}
This is how new data is added through APIs.
Example 3: Updating a user’s name
PUT /api/v1/users/1
{
"name": "Aisha"
}
The API updates the name in the database and responds with:
{
"id": 1,
"name": "Aisha",
"message": "Updated successfully"
}
Example 4: Deleting a comment
DELETE /api/v1/comments/22
This API call removes the comment with ID 22. The response might look like:
{
"status": "deleted",
"id": 22
}
Example 5: API used by a weather app
Your app asks:
GET https://api.weather.com/today?city=Karachi
The server responds:
{
"temperature": "32°C",
"condition": "Sunny"
}
This is how third-party APIs work — your app asks a question, and the API answers with data.
🔁 Alternatives
Instead of REST APIs, developers can also use:
- GraphQL: Query exactly the data needed.
- gRPC: High-performance APIs with Protocol Buffers.
- WebSockets: Real-time data communication.
❓ General Questions & Answers
Q1: What exactly is an API?
A: An API (Application Programming Interface) is like a **waiter in a restaurant** 🍽️.
You (the user) give the waiter (API) an order (request), and they bring back your food (data) from the kitchen (server).
In simple words: It’s a **bridge** that helps two apps talk to each other. Your frontend sends a request to the backend API, and it sends back the data you need — usually in JSON format.
Q2: Why do we need APIs in web apps?
A: APIs make it possible to:
- 🌐 Connect the frontend (what users see) with the backend (where data lives)
- 📱 Use the same backend for websites, mobile apps, or even third-party tools
- 🔐 Protect and structure access to data
Q3: What does a typical API response look like?
A: Most APIs respond with JSON — a structured data format that’s easy to read by both machines and humans.
Example:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com"
}
This is the kind of data your app can use to show the user’s profile, name, or email.
Q4: Is using an API the same as using a database?
A: Not exactly. An API is the **middleman** between your app and the database.
Instead of writing SQL queries in your frontend, you make requests like:
GET /api/v1/products
The backend API handles the database part and sends the result back. So it’s **safer, easier, and more organized**.
Q5: Are there different types of APIs?
A: Yes! There are:
- REST APIs – The most common, using standard HTTP methods (GET, POST, etc.)
- GraphQL APIs – You ask for exactly the data you need
- WebSocket APIs – For real-time communication (like chat apps)
- Third-party APIs – Like Facebook Login, Google Maps, or Stripe Payments
🛠️ Deep Dive Technical Q&A
Q1: How do I create a basic API in Rails?
A: In Rails, you can use a controller to return data as JSON. First, create an API namespace and use the render json:
method.
# config/routes.rb
namespace :api do
namespace :v1 do
resources :users
end
end
# app/controllers/api/v1/users_controller.rb
class Api::V1::UsersController < ApplicationController
def index
users = User.all
render json: users
end
end
Result: When you visit /api/v1/users
, it returns a list of users in JSON format.
Q2: How can I send data to the backend API?
A: Use a POST
request from the frontend to send data.
// JavaScript (frontend)
fetch('/api/v1/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Ali', email: 'ali@example.com' })
});
In Rails:
def create
user = User.new(user_params)
if user.save
render json: user, status: :created
else
render json: user.errors, status: :unprocessable_entity
end
end
This creates a new user and sends back the data as confirmation.
Q3: How do I handle API errors in Rails?
A: Use proper status codes and JSON error messages:
def show
user = User.find_by(id: params[:id])
if user
render json: user
else
render json: { error: "User not found" }, status: :not_found
end
end
Result: If the user doesn’t exist, the API responds with a 404 status and a message.
Q4: How do I secure a Rails API?
A: You can use Devise + JWT
for token-based authentication.
# Gemfile
gem 'devise'
gem 'devise-jwt'
# ApplicationController
before_action :authenticate_user!
When the user logs in, they get a token. That token is used in headers for secure API calls:
Authorization: Bearer <your_token>
Q5: How do I return custom JSON in Rails?
A: You can use as_json
or serializers like ActiveModel::Serializer
.
def show
user = User.find(params[:id])
render json: user.as_json(only: [:id, :name, :email])
end
OR use serializer:
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email
end
Then just write render json: user
— Rails will use the serializer automatically.
✅ Best Practices with Examples
1. Use namespaced and versioned routes
Always use namespaces like /api/v1/
in your routes to allow future changes without breaking existing clients.
# Good
GET /api/v1/users
# Bad
GET /users
2. Return consistent JSON responses
Structure your responses clearly using keys like data
, message
, or errors
.
# Good
{
"data": { "id": 1, "name": "Ali" },
"message": "User found"
}
# Bad
{
"id": 1,
"Ali"
}
3. Use proper HTTP status codes
Status codes help the frontend understand what happened:
200 OK
– Success201 Created
– Resource created404 Not Found
– Resource not found422 Unprocessable Entity
– Validation error
render json: { message: "Not found" }, status: :not_found
4. Use serializers for clean output
Instead of returning the full model, use serializers to return only what’s needed.
# user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email
end
# Controller
render json: @user
5. Avoid N+1 queries in APIs
Use .includes
to preload related data and avoid slow API responses.
# Good
User.includes(:posts).all
# Bad (makes too many DB calls)
User.all.each { |u| u.posts }
6. Always validate incoming data
Don’t trust the input! Use strong parameters and model validations.
# Strong params
params.require(:user).permit(:name, :email)
# Validation in model
validates :email, presence: true, uniqueness: true
7. Secure APIs with authentication
Use token-based authentication (like JWT) to protect sensitive endpoints.
# Controller
before_action :authenticate_user!
# Headers (Frontend)
Authorization: Bearer YOUR_JWT_TOKEN
🌍 Real-world Scenario
Imagine you’re building a shopping app — the kind people use to browse products and place orders.
The app itself (frontend) is built using React or a mobile framework, while the backend is a Ruby on Rails API.
When the user opens the app, the frontend sends an API request like this:
GET /api/v1/products
The Rails backend receives this request, fetches data from the database, and sends back a JSON response:
{
"products": [
{ "id": 1, "name": "Shampoo", "price": 9.99 },
{ "id": 2, "name": "Soap", "price": 3.49 }
]
}
The frontend then uses this JSON data to display the product list. The user sees product cards with names and prices — all powered by the API.
When the user adds an item to the cart, the app sends another API call:
POST /api/v1/cart_items
{
"product_id": 2,
"quantity": 1
}
The backend handles it and updates the user’s cart. The app now shows a “1 item in cart” badge — all thanks to API communication.
🔄 Every action — logging in, placing an order, tracking shipments — is done through API requests between the frontend and the Rails backend.
This setup also allows developers to build mobile apps using the same API — no need to rebuild logic. The API acts as the single source of truth.
💡 That’s how APIs work in the real world — they let apps talk to each other safely and smartly.
When to use rails new myapp --api
🧠 Detailed Explanation
The command rails new myapp --api
is used to create a special kind of Rails app — called an API-only app.
A normal Rails app can build full websites with pages, buttons, forms, and layouts (HTML, CSS, JavaScript).
But when you use --api
, you’re telling Rails:
“I don’t need a website, just the backend that gives or saves data using JSON.”
This kind of app is:
- 🔁 Good for working with React, Vue, or mobile apps (like iOS or Android)
- ⚡ Faster and smaller because it skips the view templates, CSS, and JS files
- 🧱 Focused only on handling data — like a waiter that only serves dishes, not the whole restaurant experience
So, use rails new myapp --api
when:
- You’re building a **backend-only** project
- Your frontend is built separately (in React, Angular, etc.)
- You want to return only **JSON responses** (not full HTML pages)
🎯 Think of it like this:
Regular Rails app = Full restaurant 🍽️
API-only Rails app = Takeout window for data 📦
💡 Examples
Example 1: You’re building a React frontend and need a backend API
You decide to build the frontend of your app using React, and you need a backend to handle user data, logins, and product listings.
Since you don’t need HTML views or pages from Rails (React will do that), you run:
rails new shop-api --api
This creates a Rails app that only returns JSON
data. Your React frontend can now ask:
GET /api/v1/products
And the API responds like this:
[
{ "id": 1, "name": "Shampoo", "price": 9.99 },
{ "id": 2, "name": "Toothpaste", "price": 2.49 }
]
Your React app uses this data to display products in a user-friendly way.
Example 2: Building an API for a mobile app
You’re hired to make a backend for a food delivery app for iOS and Android.
Since the app only needs to fetch data (like restaurants and orders) and send it in JSON format, you use:
rails new food-delivery-api --api
Now the mobile app can make API calls like:
POST /api/v1/orders
{
"restaurant_id": 5,
"items": [1, 3, 4]
}
The Rails app saves the order and returns a confirmation:
{
"order_id": 101,
"status": "received"
}
Example 3: Creating a microservice
You have a big app and want to split it into smaller services. One service only handles notifications (email, SMS, etc.).
It doesn’t need views — just endpoints. So you use:
rails new notification-service --api
This keeps it lightweight, fast, and focused only on sending and tracking messages via API calls.
Example 4: Creating an API-only backend for a third-party integration
Your company wants to expose product data to other companies (e.g., partners or affiliates).
Instead of giving them database access, you build a clean, secure API:
rails new partner-api --api
Now they can use:
GET /api/v1/products
This gives them read-only access to your catalog, without touching your full app.
🔁 Alternatives
- Use
rails new myapp
(without--api
) if you’re rendering HTML pages too. - You can convert a regular Rails app to API-only by setting
config.api_only = true
inapplication.rb
.
❓ General Questions & Answers
Q1: What does rails new myapp --api
actually do?
A: It tells Rails to create a special kind of app that is focused only on **serving JSON data** (no HTML, no layouts, no CSS). It’s perfect when you’re building a backend that talks to a separate frontend (like React, Vue, or a mobile app).
It skips view-related files and includes only the essentials: models, controllers, routes, and serializers.
Q2: Can I still use database and models in API-only mode?
A: Yes! You can use **ActiveRecord** just like in a full Rails app. You can create models, run migrations, and interact with the database normally. The only difference is that data is sent and received as JSON, not HTML pages.
Q3: When should I NOT use --api
?
A: You should avoid using --api
if:
- You plan to use Rails for both backend and frontend (like rendering HTML pages)
- You want to use built-in Rails views, layouts, helpers, or ActionView templates
Q4: Can I add views later to an API-only app?
A: Technically yes, but it’s not recommended. You’d have to manually re-enable middleware and include gems that were skipped (like Sprockets or ActionView). If you think you might need views later, better to start with a normal Rails app.
Q5: Is an API-only app faster than a full Rails app?
A: Yes. Since it doesn’t load view-related components, stylesheets, or unnecessary middleware, it starts faster and consumes fewer resources — especially helpful in microservices or apps with high traffic.
Q6: Can I still use authentication in an API-only app?
A: Absolutely. You can use Devise + JWT
or OAuth tokens. Instead of sessions and cookies, API apps use token-based authentication, which works better for frontend apps and mobile devices.
🛠️ Deep Dive Technical Q&A
Q1: What files are missing when I use --api
?
A: When you run rails new myapp --api
, Rails skips these things:
- No
app/views
folder (because you’re not rendering HTML) - No asset pipeline (no CSS/JS via Rails)
- No layouts, helpers, or frontend-related middleware
It still includes controllers, models, routes, and JSON responses.
Q2: How do I return JSON from a controller in an API-only app?
A: Just use render json:
in your controller action.
# app/controllers/api/v1/products_controller.rb
class Api::V1::ProductsController < ApplicationController
def index
products = Product.all
render json: products
end
end
This will return the product list in JSON format.
Q3: How do I organize my routes in an API-only app?
A: It’s best practice to namespace your API routes by version:
# config/routes.rb
namespace :api do
namespace :v1 do
resources :products
end
end
Now your endpoint will look like /api/v1/products
.
Q4: Can I add middleware like CORS to an API-only app?
A: Yes! You can enable and configure middleware like CORS in config/application.rb
.
# Gemfile
gem 'rack-cors'
# config/application.rb
config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*', headers: :any, methods: [:get, :post, :put, :delete]
end
end
This is essential when your frontend is hosted on a different domain.
Q5: How can I handle errors in an API-only app?
A: Use conditional rendering and HTTP status codes:
def show
user = User.find_by(id: params[:id])
if user
render json: user
else
render json: { error: "User not found" }, status: :not_found
end
end
This way, your API gives useful responses that clients can understand.
Q6: What authentication should I use in API-only apps?
A: Use **token-based authentication** (like JWT). Avoid sessions and cookies.
For example, using Devise + devise-jwt
allows secure login via API and stores the token on the client.
Frontend apps then send:
Authorization: Bearer <your_token>
✅ Best Practices with Examples
1. Always use versioned API routes
This helps you maintain backward compatibility when updating your API in the future.
# config/routes.rb
namespace :api do
namespace :v1 do
resources :users
end
end
# Now the endpoint is /api/v1/users
2. Return consistent JSON responses
Your API should always return predictable structures (e.g., data
, errors
, message
).
# Good JSON response
{
"data": {
"id": 1,
"name": "Ali"
},
"message": "User loaded successfully"
}
3. Use serializers to format output
Serializers give you control over what data you return and how it’s shaped.
# app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email
end
# Controller
render json: @user
4. Use proper HTTP status codes
This helps frontend apps understand what happened without relying only on messages.
200 OK
– Request was successful201 Created
– New resource created404 Not Found
– Resource doesn’t exist422 Unprocessable Entity
– Validation failed
render json: { error: "User not found" }, status: :not_found
5. Use token-based authentication (JWT)
API-only apps don’t use sessions. Use tokens that the frontend stores and sends in headers.
Authorization: Bearer YOUR_ACCESS_TOKEN
Use gems like devise-jwt
or knock
for token handling.
6. Enable CORS for frontend requests
If your frontend is on a different domain, configure CORS to allow communication.
# Gemfile
gem 'rack-cors'
# config/application.rb
config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*', headers: :any, methods: [:get, :post, :patch, :put, :delete]
end
end
7. Validate all incoming parameters
Use strong parameters and model validations to prevent bad data or security issues.
# In controller
params.require(:user).permit(:name, :email)
# In model
validates :email, presence: true, uniqueness: true
🌍 Real-world Scenario
Imagine you’re building a startup that offers a food delivery app like Foodpanda or Uber Eats. You want the app to work on:
- 📱 iOS and Android (mobile)
- 💻 A React-based admin dashboard for restaurant owners
Instead of building the backend two or three times for each frontend, you build **one API-only Rails app** that:
- ✅ Accepts orders from mobile apps
- ✅ Lets restaurants update their menus from a React dashboard
- ✅ Sends real-time notifications to delivery drivers
You start the project like this:
rails new food-backend --api
Now, everything the frontend needs — from user login to listing available meals — is handled via JSON API endpoints:
POST /api/v1/orders
GET /api/v1/restaurants
PATCH /api/v1/menu_items/42
🔄 The frontend apps (React, iOS, Android) all “talk” to this single backend through these endpoints.
✅ It’s fast, clean, and scalable — and all made possible with rails new myapp --api
.
Full Rails App vs API-only Mode
🧠 Detailed Explanation
A Full Rails App is like a full restaurant 🍽️ — it cooks the food (backend), serves it beautifully on a plate (HTML), gives you the cutlery (CSS and JS), and even decorates the table (layouts, views, helpers).
On the other hand, an API-only Rails App is like a food delivery kitchen 🚚 — it just prepares the food (data), packs it in a box (JSON), and delivers it to your house (frontend app).
In technical terms:
- Full Rails App includes everything: views, templates, stylesheets, JavaScript, and server-rendered pages.
- API-only App includes only what’s needed to serve data: models, controllers, and JSON responses — no views or HTML.
Use a Full Rails App when you’re building a traditional website with HTML pages (like a blog or admin panel).
Use an API-only App when you’re building a backend that will serve a mobile app, a React frontend, or another system that expects JSON.
🎯 Quick Summary:
- 📄 Full Rails = Build websites
- 📦 API-only = Serve data to other apps
💡 Examples
Example 1: Full Rails App – Blog Website
You want to create a blog where users can read posts, write comments, and browse by category — all through web pages.
You use a full Rails app because it gives you:
- HTML templates for pages
- Built-in forms and layout system
- Helpers for rendering views and links
rails new blog-app
Now you can build pages like:
/posts
, /about
, /login
, etc.
Example 2: API-only Rails App – Mobile App Backend
You’re building a mobile app for a fitness tracker. The app shows workouts and tracks progress.
The mobile app needs data from a server, not web pages. So you build a Rails backend that only sends JSON:
rails new fitness-api --api
API Endpoints:
GET /api/v1/workouts
→ returns workout data as JSONPOST /api/v1/progress
→ saves new workout progress
Example 3: Full Rails Admin + API-only App for Clients
A company uses two apps:
- A full Rails admin dashboard to manage users, settings, and reports
- An API-only backend that mobile users interact with through the app
🔁 Alternatives
- Start with full Rails, then convert to API-only by setting
config.api_only = true
- Use Node.js, Laravel, or Django REST Framework for building APIs
❓ General Questions & Answers
Q1: What’s the main difference between full Rails and API-only mode?
A: A full Rails app builds and serves complete web pages (HTML, CSS, JS), while an API-only app just sends and receives data (usually in JSON) to other apps like mobile or frontend frameworks (React, Vue).
Q2: Which one should I use for a React or mobile app?
A: Use rails new myapp --api
for React or mobile apps. These apps handle their own design and only need data from the backend.
Q3: Can I build both website and API in the same full Rails app?
A: Yes! A full Rails app can render HTML pages and also provide API endpoints by responding with JSON.
But if you’re not using views at all, it’s better to use API-only mode for performance and simplicity.
Q4: Is an API-only app faster?
A: Yes. Since it doesn’t load view templates, layout engines, or assets, it starts faster and uses fewer resources — great for microservices or backend-only apps.
Q5: Can I add views to an API-only app later?
A: Technically yes, but you’ll need to re-enable middleware and manually add features for views, layouts, and static assets. It’s possible, but not ideal — so choose carefully at the start.
🛠️ Technical Q&A
Q1: What’s missing in an API-only Rails app?
A: When you create an API-only app using --api
, Rails skips:
- 🧾 View templates (no
app/views
) - 🎨 Asset pipeline (no CSS or JS files managed by Rails)
- 🍪 Sessions, flash messages, cookies
- 🔧 Some middleware like ActionView
Q2: How do I return JSON in both modes?
A: It’s the same in both:
# app/controllers/api/v1/users_controller.rb
def index
users = User.all
render json: users
end
✅ This works whether you’re in full Rails mode or API-only.
Q3: Can I still use Devise or authentication gems in API-only apps?
A: Yes, but you need to configure them for **token-based auth** (like JWT), since API-only mode doesn’t use sessions or cookies.
Example: devise + devise-jwt
allows you to secure API endpoints and send tokens in request headers.
Q4: How do I enable CORS in API-only mode?
A: Use the rack-cors
gem to allow requests from your frontend app (React, mobile, etc.):
# Gemfile
gem 'rack-cors'
# config/application.rb
config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete]
end
end
Q5: Can I change an API-only app to full Rails later?
A: Yes, but it’s manual. You’ll need to:
- Add back middleware like
ActionDispatch::Cookies
- Install view and asset-related gems
- Create the
app/views
and asset folders yourself
✅ Best Practices with Examples
1. Use API-only mode for mobile or JavaScript frontends
If your frontend is built with React, Vue, Angular, or is a mobile app — your Rails app doesn’t need to render HTML. Use --api
mode for a faster, cleaner backend.
rails new taskmanager-backend --api
2. Use full Rails mode when you need HTML pages
For apps like blogs, admin dashboards, or marketing websites that use Rails to generate HTML, forms, and layouts — go with the full version of Rails.
rails new company-website
3. Keep API-only apps slim and focused
Don’t add view-related gems, templates, or helpers. Focus on:
- ✔️ Clean routing
- ✔️ JSON serialization (e.g.,
ActiveModel::Serializer
) - ✔️ Token-based auth (e.g.,
JWT
)
4. Namespace your API routes with versions
This helps you upgrade your API in the future without breaking old clients.
namespace :api do
namespace :v1 do
resources :users
end
end
# ➤ /api/v1/users
5. Use proper status codes and error responses
Always send the right HTTP status codes for frontend handling:
- 200 – OK
- 201 – Created
- 404 – Not Found
- 422 – Validation Error
render json: { error: "User not found" }, status: :not_found
6. Use CORS for cross-domain frontends
If your frontend is on a different domain (like localhost:3000
for React), enable CORS to allow communication.
# config/application.rb
config.middleware.insert_before 0, Rack::Cors do
allow do
origins '*'
resource '*', headers: :any, methods: [:get, :post]
end
end
7. Keep full Rails apps modular if adding API support
If you need both web pages and API, separate your routes and controllers clearly:
- Use
/api/v1/*
for JSON responses - Use normal routes for views (e.g.,
/admin
)
🌍 Real-world Scenario
Let’s say you’re working at a startup building a platform like Udemy — where users can take courses online.
Here’s how you use both Rails modes in the same company:
- 🖥️ Marketing Website (Full Rails App):
You build the landing page, pricing, and blog using the full Rails stack with views, layouts, and helper methods.
rails new udemy-marketing
- 📱 Mobile & React App Backend (API-only):
The actual course app — used by mobile and frontend teams — connects to a lightweight Rails API-only backend for login, video content, and progress tracking.
rails new udemy-api --api
The API backend sends JSON data like:
GET /api/v1/courses
[
{ "id": 1, "title": "Intro to Rails" },
{ "id": 2, "title": "React for Beginners" }
]
The mobile app or frontend reads this data and displays it beautifully — while the backend just focuses on delivering it fast and securely.
🚀 This combo gives you the best of both worlds:
- ✅ Full Rails for quick content + server-rendered pages
- ✅ API-only for fast JSON communication with frontend and mobile apps
HTTP Verbs & Status Codes (GET, POST, PUT/PATCH, DELETE)
🧠 Detailed Explanation
When your frontend (like React, mobile app, or browser) talks to the backend (Rails app), it sends a request using something called an HTTP verb — this tells the server what action you want to do.
Here are the most common verbs:
- GET – “Give me data” (read)
- POST – “Here’s new data” (create)
- PUT / PATCH – “Change this data” (update)
- DELETE – “Remove this data” (delete)
When the backend receives the request, it sends back a status code to let you know if the request was successful or if something went wrong.
Think of it like sending a letter:
- Verb = What you want (e.g., “I need info”)
- Status code = Their response (e.g., “OK, here it is”)
Common status codes:
- 200 – Success ✅
- 201 – Created (e.g., after saving a new user)
- 204 – Deleted, no content
- 400 – Bad request (something was missing or wrong)
- 404 – Not found 🚫
- 422 – Validation error (e.g., missing name or email)
- 500 – Server error 🔥
💡 In Rails, these verbs map to controller actions:
GET
→index
,show
POST
→create
PATCH / PUT
→update
DELETE
→destroy
✅ If your API follows these verbs and uses correct status codes, the frontend can easily understand and display results properly.
💡 Examples
Example 1: GET – Fetch data
You want to get a list of users.
Request: GET /api/v1/users
Response: 200 OK
[
{ "id": 1, "name": "Ali" },
{ "id": 2, "name": "Aisha" }
]
✅ Use GET when you just want to “read” data without changing anything.
Example 2: POST – Create new data
You’re signing up a new user.
Request: POST /api/v1/users
Body:
{
"name": "Fatima",
"email": "fatima@example.com"
}
Response: 201 Created
{
"id": 3,
"name": "Fatima"
}
✅ Use POST when you want to create something new.
Example 3: PATCH – Update some data
You want to update just the name of a user.
Request: PATCH /api/v1/users/1
Body:
{
"name": "Ali Raza"
}
Response: 200 OK
{
"id": 1,
"name": "Ali Raza"
}
✅ Use PATCH when you want to change part of a record (not everything).
Example 4: PUT – Replace a resource completely
Let’s say you’re replacing an entire user record.
Request: PUT /api/v1/users/1
Body:
{
"name": "Ali Raza",
"email": "ali@example.com"
}
Response: 200 OK
✅ Use PUT if you’re sending all fields and want to overwrite the full record.
Example 5: DELETE – Remove a resource
You want to delete a user.
Request: DELETE /api/v1/users/1
Response: 204 No Content
✅ Use DELETE when you want to remove a record completely.
Example 6: Validation Error (Bad data)
You try to create a user without an email:
POST /api/v1/users
Body: { "name": "Test" }
Response: 422 Unprocessable Entity
{
"errors": {
"email": ["can't be blank"]
}
}
🚫 Rails sends back an error with a 422
status and a message explaining what went wrong.
🔁 Alternatives
Instead of using PUT (which replaces entire resources), use PATCH for partial updates. Also, for complex APIs, consider using GraphQL which uses a single POST endpoint but allows rich queries.
❓ General Questions & Answers
Q1: What is an HTTP verb?
A: An HTTP verb tells the server what action you want to take. Just like you say “I want to GET something” or “I want to DELETE something.” It’s the action part of the request.
Q2: What’s the difference between PUT and PATCH?
A: Both are used to update data. But:
- PATCH = update part of a record (e.g., just the name)
- PUT = replace the whole record (send all fields again)
Q3: What is a status code?
A: A status code is a 3-digit number in the response that tells you what happened. Examples:
- 200 – Success
- 201 – Created
- 404 – Not found
- 422 – Validation error
Q4: Why is 204 used after DELETE?
A: 204 means “No Content.” It confirms the request worked, but there’s nothing to send back — perfect for delete operations.
Q5: Can I use GET to create or delete something?
A: ❌ No! GET should never change data. It’s used only to read or fetch data. For creating or deleting, use POST or DELETE instead — this is part of REST best practices.
Q6: What if I send wrong or missing data?
A: Rails will respond with 422 Unprocessable Entity
and a list of errors showing what’s missing or invalid. This helps the frontend show proper messages to the user.
🛠️ Technical Q&A
Q1: What Rails controller actions match HTTP verbs?
A: Rails maps HTTP verbs to RESTful actions automatically:
GET
→index
(list),show
(single item)POST
→create
PATCH/PUT
→update
DELETE
→destroy
Q2: How do I return a proper status code in Rails?
A: Use the render
method with a status:
option:
render json: { message: "Created" }, status: :created # 201
render json: { error: "Not found" }, status: :not_found # 404
render json: @user, status: :ok # 200
Q3: What status code should I return after DELETE?
A: Return 204 No Content
to indicate success with no response body.
head :no_content
# or
render json: {}, status: :no_content
Q4: How do I handle validation errors?
A: If validation fails (e.g., missing required fields), use 422
with error messages:
if @user.save
render json: @user, status: :created
else
render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity
end
Q5: Can I use PATCH and PUT in Rails?
A: Yes! Rails treats them both as update
, but PATCH is preferred for partial updates. In your routes file:
resources :users
# Handles:
# PATCH /users/:id → users#update
# PUT /users/:id → users#update
Q6: How can I test different HTTP verbs in development?
A: Use tools like:
- Postman (UI-based)
- curl (CLI-based):
curl -X GET http://localhost:3000/api/v1/users
curl -X POST -H "Content-Type: application/json" -d '{"name":"Ali"}' http://localhost:3000/api/v1/users
curl -X PATCH -H "Content-Type: application/json" -d '{"name":"Updated"}' http://localhost:3000/api/v1/users/1
curl -X DELETE http://localhost:3000/api/v1/users/1
✅ Best Practices with Examples
1. Use the right HTTP verb for each action
Follow RESTful standards so your API is predictable and easy to use.
GET
– Fetch data (read-only)POST
– Create a new recordPATCH/PUT
– Update existing recordsDELETE
– Remove records
2. Always return appropriate HTTP status codes
This makes it easier for clients (frontend, mobile) to know what happened.
render json: @user, status: :ok # 200 OK
render json: @user, status: :created # 201 Created
head :no_content # 204 No Content (after DELETE)
render json: { error: "Not found" }, status: :not_found # 404
3. Use PATCH for partial updates, not PUT
If you’re only updating one or two fields (e.g., name), use PATCH. Save PUT for full replacements.
# PATCH
PATCH /users/1
{ "name": "Updated Name" }
# PUT (replaces everything)
PUT /users/1
{ "name": "Updated Name", "email": "new@example.com" }
4. Use 422 Unprocessable Entity for validation errors
Don’t return 200 for bad data — send the right status code and include error messages.
render json: { errors: @user.errors }, status: :unprocessable_entity
# → 422
5. Never modify data with GET requests
GET is only for reading. Changing data with GET can lead to serious bugs and security issues.
6. Use tools to test each verb clearly
Tools like Postman, Insomnia, or curl help verify that each route responds correctly to each verb.
7. Document each endpoint clearly with verb + status code
This helps other developers (and future-you) understand how to use your API:
GET /api/v1/users → 200 OK
POST /api/v1/users → 201 Created
PATCH /api/v1/users/:id → 200 OK / 422
DELETE /api/v1/users/:id → 204 No Content
🌍 Real-world Scenario
Imagine you’re building a to-do list app with a mobile frontend and a Rails API backend.
Here’s how your app would use different HTTP verbs and status codes:
- 📥 GET /api/v1/tasks
The app fetches a list of tasks from the server.
✅ Status:200 OK
- ➕ POST /api/v1/tasks
The user adds a new task like “Buy milk”.
✅ Status:201 Created
💬 Response: JSON with the new task’s ID and content - ✅ PATCH /api/v1/tasks/7
The user marks task #7 as completed.
✅ Status:200 OK
💬 Response: JSON showing updated task - 🗑️ DELETE /api/v1/tasks/7
The user deletes the task.
✅ Status:204 No Content
💬 No body returned — just a signal that it’s gone - ❌ POST /api/v1/tasks with empty title
The user tries to create a task without a name.
⚠️ Status:422 Unprocessable Entity
💬 Response:{ "errors": { "title": ["can't be blank"] } }
The frontend uses these status codes to show helpful messages:
- ✅ “Task added successfully” (201)
- ✅ “Task deleted” (204)
- ⚠️ “Please enter a task name” (422)
✅ This kind of clear communication between frontend and backend makes your app smoother, faster, and easier to maintain.
Rails Flow: Routes → Controller → Service → Model → Serializer
🧠 Detailed Explanation
In a Rails API, when someone makes a request (like asking for all posts), it goes through a clear step-by-step path. This is how it works:
- 📍 Route: The entry point. It listens for a specific URL, like
/posts
, and sends the request to the right controller. - 🎮 Controller: Think of it like the manager. It receives the request, checks what needs to happen, and delegates the work to a service or model.
- 🧠 Service (optional): This is a helper class that contains business logic — for example, “only return posts created in the last 7 days.”
- 🗄️ Model: This talks directly to the database and fetches or saves data using ActiveRecord.
- 📦 Serializer: Before the data is sent back as JSON, the serializer formats it — like a clean package that only includes what the frontend needs (id, title, etc.).
🧭 Think of the flow like a package delivery:
Route = address → Controller = delivery manager → Service = handles special requests → Model = warehouse → Serializer = final packaging
This pattern makes your Rails code:
- ✅ Easier to read and manage
- ✅ Organized into clear responsibilities
- ✅ Scalable for large apps
🧪 Implementation
Goal: Build an API endpoint that returns all blog posts with clean JSON formatting.
1. Step: Define the Route
# config/routes.rb
get '/posts', to: 'posts#index'
📌 This tells Rails: when someone visits /posts
, use the index
action in the PostsController
.
2. Step: Create the Controller
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def index
posts = PostService.new.fetch_all
render json: PostSerializer.new(posts)
end
end
🎮 The controller receives the request and uses a service to get the data.
3. Step: Add a Service to Handle Logic
# app/services/post_service.rb
class PostService
def fetch_all
Post.includes(:comments).order(created_at: :desc)
end
end
⚙️ This service fetches posts from the database — already sorted and optimized.
4. Step: Use the Model to Talk to the Database
# app/models/post.rb
class Post < ApplicationRecord
has_many :comments
end
🗄️ This model knows how to query the posts
table and its relationships.
5. Step: Format the JSON Using a Serializer
# app/serializers/post_serializer.rb
class PostSerializer
include JSONAPI::Serializer
attributes :id, :title, :created_at
end
📦 The serializer returns only the fields we want — clean and secure JSON for the frontend.
🔁 Alternatives
You can skip services for simple actions and call models directly from the controller. Or, use Presenters or Decorators instead of Serializers for formatting logic.
❓ General Questions & Answers
Q1: What does the route do in a Rails app?
A: The route is like a traffic sign. It tells Rails which controller and action should handle the request. For example:
get '/users', to: 'users#index'
This means when someone sends a GET request to /users
, Rails should use the index
method in UsersController
.
Q2: What’s the role of a controller in this flow?
A: The controller receives the request, prepares the logic, and gives a response. It’s like a receptionist — directing the request to the right place (models, services, serializers).
Q3: Why do we use a service layer?
A: The service layer contains business logic — like filtering records, calculating totals, or sending emails. It helps keep the controller short and clean (also called “thin controller, fat model/service”).
Q4: Can I use models directly in the controller?
A: Yes, and it’s fine for simple actions. But for larger apps or more logic-heavy tasks, services make your app easier to maintain and test.
Q5: What is a serializer, and why is it useful?
A: A serializer formats the data before it’s sent to the frontend. It lets you control exactly what gets returned — no extra fields, no private data. It’s like a filter or template for JSON output.
Q6: Is this flow required in all Rails apps?
A: No, this is a best-practice structure used mostly in Rails API applications or large systems. You can start small with routes, controllers, and models — and add services and serializers as your app grows.
🛠️ Deep Dive Technical Q&A
Q1: How does Rails know which controller to call?
A: Routes define the URL-to-controller mapping. In config/routes.rb
you write:
get '/posts', to: 'posts#index'
This tells Rails: if someone visits /posts
, go to PostsController
and run the index
method.
Q2: Can I pass params from the controller to the service?
A: Yes! Services are plain Ruby classes, so you can pass anything — filters, IDs, request data, etc.
posts = PostService.new(current_user, params).fetch_filtered
This allows your service to handle logic based on the user or filters.
Q3: Should services be single-purpose or multi-action?
A: Ideally, services should focus on one task (e.g., CreatePostService
, SendInvoiceService
). Keeping them small improves testing and readability.
Q4: What’s the benefit of using JSONAPI::Serializer?
A: It’s lightweight, fast, and lets you define attributes and relationships clearly:
class PostSerializer
include JSONAPI::Serializer
attributes :id, :title, :created_at
end
This helps your API return consistent, clean, and frontend-ready JSON.
Q5: Can I use multiple services in one controller action?
A: Yes — but avoid stuffing too much logic into one place. You can delegate multiple steps to separate services for better separation of concerns.
Q6: Where should I store my services?
A: Best practice is to place them in app/services
. For organized apps, you can even namespace them:
app/services/posts/fetch_popular_service.rb
This keeps your app scalable and your logic modular.
✅ Best Practices with Examples
1. Keep your controllers thin and focused
A controller should only handle request/response flow — move business logic to service objects.
# ✅ Good
def create
result = PostCreationService.new(params).call
render json: PostSerializer.new(result)
end
2. Use services for reusable, testable business logic
Instead of repeating logic in multiple controllers or models, move it into services:
class PaymentService
def initialize(order)
@order = order
end
def process
# logic to charge customer, send receipt, etc.
end
end
3. Only expose necessary data with serializers
This prevents leaking sensitive fields and ensures the frontend gets only what it needs.
class UserSerializer
include JSONAPI::Serializer
attributes :id, :name, :email # Avoid sending password_digest, etc.
end
4. Use model scopes and validations to keep models clean
Models should handle validations and basic querying, but not complex business rules.
class Post < ApplicationRecord
scope :recent, -> { order(created_at: :desc).limit(10) }
validates :title, presence: true
end
5. Organize services into folders by domain
This helps keep large codebases maintainable:
app/services/
users/
registration_service.rb
password_reset_service.rb
posts/
publish_service.rb
archive_service.rb
6. Always rescue and handle exceptions gracefully in services
This avoids crashing your API and helps return meaningful errors to the frontend.
def call
# risky logic
rescue SomeError => e
Rails.logger.error(e.message)
nil
end
7. Use route versioning early in API design
To support future updates without breaking clients:
# config/routes.rb
namespace :api do
namespace :v1 do
resources :posts
end
end
🌍 Real-world Scenario
Imagine you’re building a job portal API like LinkedIn or Indeed. Users can browse job listings, apply, and track applications.
Let’s say the frontend sends a request to fetch all active job posts with company info.
- 🔗 Route: You define the endpoint:
GET /api/v1/jobs
JobsController#index
handles the request:
def index
jobs = JobFetcherService.new(current_user, params).call
render json: JobSerializer.new(jobs)
end
JobFetcherService
applies filters like location, remote, salary, etc.:
class JobFetcherService
def initialize(user, params)
@user = user
@params = params
end
def call
Job.active.where(location: @params[:location]).limit(20)
end
end
Job
model contains scopes and validations:
class Job < ApplicationRecord
scope :active, -> { where(status: 'active') }
validates :title, :company_name, presence: true
end
JobSerializer
formats the output:
class JobSerializer
include JSONAPI::Serializer
attributes :id, :title, :location, :company_name, :salary_range
end
✅ The frontend receives clean, structured JSON it can display in job cards:
[{ "title": "Rails Developer", "location": "Remote" }]
🧠 This flow keeps business logic inside services, database rules inside models, and output structure inside serializers — resulting in a clean, fast, and easy-to-maintain API.
render json: and head :no_content
🧠 Detailed Explanation
In Rails, when you’re building an API (like a backend for a mobile or React app), you don’t send HTML pages — you send data, usually in JSON format. That’s where these two come in:
-
render json:
is used to send back data in JSON format. This is how you respond to a request when you want to return a user, post, or list of products. -
head :no_content
is used when the request is successful, but you don’t need to send back any data — like after deleting a record.
✅ In simple words:
render json: something
→ “Here’s the data you asked for.”head :no_content
→ “I did what you asked, but there’s nothing to return.”
💡 This keeps your API responses clean, fast, and easy for frontend apps to understand.
🧠 Think of it like this:
- render json: = 📦 Sending a package (data)
- head :no_content = 👍 “Task done, no package needed”
💡 Examples
Example 1: Show user data using render json:
You want to return a user’s profile in JSON format:
def show
user = User.find(params[:id])
render json: user
end
🧾 The client will receive:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com"
}
Example 2: Return a list of posts
When a frontend calls /posts
, you return all posts in JSON:
def index
posts = Post.all
render json: posts
end
Example 3: Use render json:
with a status
You just created a new comment, and want to return it with a 201 Created
status:
def create
comment = Comment.create!(comment_params)
render json: comment, status: :created
end
Example 4: Use head :no_content
after deleting
When a task is deleted, there’s no need to return data — just confirmation:
def destroy
task = Task.find(params[:id])
task.destroy
head :no_content
end
✅ The response will have a 204 No Content
status — meaning success, but nothing to display.
Example 5: Conditional use of head :no_content
Sometimes you want to return “nothing” only if there’s truly no data to send:
def cleanup_empty_notes
if Note.where(content: "").destroy_all.any?
head :no_content
else
render json: { message: "No empty notes found." }
end
end
🔁 Alternatives
- Use
render plain:
for text responses. - Use
render status:
if you only want to send status without content, e.g.render status: :ok
. - Use
render json: {}, status: :no_content
for similar effect with custom logic.
❓ General Questions & Answers
Q1: What does render json:
do in Rails?
A: It takes a Ruby object (like a model or array), converts it into JSON, and sends it back as the HTTP response. It’s how your backend gives data to the frontend.
Example:
render json: @user
# → Returns something like:
# { "id": 1, "name": "Aisha" }
Q2: What does head :no_content
mean?
A: It tells Rails to send a 204 No Content HTTP response — which means the action was successful, but there’s nothing to return (often used after delete operations).
Q3: When should I use render json:
?
A: Use it when you want to return data from your Rails app — such as user info, a list of posts, or an error message.
Q4: When should I use head :no_content
?
A: Use it when:
- ✅ You completed the action (like delete)
- ✅ The client doesn’t need any data back
- ✅ You want to return a success status but no body
Q5: Will head :no_content
show anything in the response body?
A: No — it sends a blank response with HTTP status 204
, which is understood by the frontend as “done, no content.”
Q6: Can I add headers when using head
or render
?
A: Yes! You can customize the response with headers or status:
head :no_content, location: some_url
render json: @user, status: :created, location: user_url(@user)
🛠️ Deep Dive Technical Q&A
Q1: What is the default status code for render json:
?
A: Rails defaults to 200 OK
unless you specify another one using status:
. For example:
render json: @post, status: :created # 201 Created
Q2: What is returned by head :no_content
?
A: It returns an HTTP response with:
Status: 204
- No response body
- No Content-Type
# In logs:
Completed 204 No Content in 12ms
Q3: Can I render both JSON and a status?
A: Yes. It’s common to combine both:
render json: { message: "Created" }, status: :created
🔄 This helps the frontend know both what happened and what status code to expect.
Q4: What if I call render json:
and then call head
?
A: Only the **first render/response method** is honored. Rails will ignore anything after it and log a warning.
render json: @user
head :no_content # Ignored!
Q5: Can I use render json: []
to return empty data?
A: Yes! For example, when no records match a query, you can still return an empty array with 200 OK
:
render json: []
This is better than 204 No Content
when the frontend expects a list.
Q6: What’s the difference between head :ok
and head :no_content
?
A:
head :ok
→ 200 OK (usually used with text/plain or other non-JSON responses)head :no_content
→ 204 No Content (used when the response is intentionally blank)
✅ Best Practices with Examples
1. Always return JSON in API responses
Use render json:
consistently to return data. Don’t mix in HTML or plain text unless absolutely necessary.
# ✅ Good
render json: @user
# ❌ Avoid this in APIs
render plain: "User created"
2. Use status:
for clarity
Include proper status codes for every response — this helps the frontend know what happened.
render json: @post, status: :created # 201
render json: errors, status: :unprocessable_entity # 422
3. Use head :no_content
after DELETE or empty successful actions
Don’t send back useless JSON like {}
or { success: true }
unless needed. Use 204 when there’s nothing to return.
def destroy
Post.find(params[:id]).destroy
head :no_content
end
4. Avoid multiple render/head calls in one action
Only the first response method works. The rest will be ignored and might throw warnings.
# ✅ Correct
render json: @user
# ❌ Don't do this
render json: @user
head :ok # Ignored!
5. Return empty arrays, not :no_content
, for list endpoints
Frontend apps usually expect a consistent JSON structure (like arrays), even if it’s empty.
# ✅ Good for frontend
render json: [] # Returns 200 with []
# ❌ Avoid
head :no_content # Returns 204 with nothing (may break UI expectations)
6. Use serializers to format complex JSON
Instead of rendering full ActiveRecord objects, use a serializer to return only the fields you need:
render json: UserSerializer.new(@user)
7. Add helpful messages when needed
When returning custom messages, send them with JSON and status:
render json: { message: "Post created successfully" }, status: :created
🌍 Real-world Scenario
Imagine you’re building a task management app (like Trello or Asana). You have a React frontend and a Rails API backend.
Here’s how your Rails backend might use render json:
and head :no_content
in actual API endpoints:
-
✅ Create a new task
Frontend sends a POST request to/tasks
with task data.
Rails controller:
The frontend receives:def create task = Task.create!(task_params) render json: task, status: :created end
{ "id": 12, "title": "Buy milk" }
with a 201 status. -
✅ Fetch a list of tasks
Frontend sends a GET request to/tasks
to display them on the dashboard.
The frontend sees an array of tasks in JSON.def index tasks = Task.all render json: tasks end
-
✅ Delete a task
Frontend sends a DELETE request to/tasks/12
.
Rails handles it like this:
No JSON is returned — just a 204 No Content status. The frontend removes the task from the UI.def destroy Task.find(params[:id]).destroy head :no_content end
🧠 Why it works so well:
render json:
sends useful data (like newly created records)head :no_content
is fast and lightweight for “action complete” responses
✅ This approach keeps your API fast, predictable, and easy for frontend developers to integrate with.
HTTP Status Codes: 200, 201, 204, 404, 422, 500
🧠 Detailed Explanation
Whenever your Rails app responds to a request, it includes a special number called a status code to tell the frontend what happened.
Think of status codes like short replies in a conversation:
- 200 OK – ✅ Everything went fine. You get the data you asked for.
- 201 Created – 🎉 Something new was created (like a new user or post).
- 204 No Content – ✅ The request worked, but there’s nothing to send back (like after deleting something).
- 404 Not Found – ❌ The thing you asked for doesn’t exist.
- 422 Unprocessable Entity – ⚠️ You sent the request right, but the data was invalid (like a blank name).
- 500 Internal Server Error – 🔥 Something went wrong on the server. It’s not your fault as the client.
These codes help the frontend (or browser, mobile app, etc.) know how to respond — whether to show a success message, show an error, or retry.
✅ Quick Summary:
- 📦 200 – Got the data
- 🆕 201 – New record created
- 🚫 204 – Success but no data to return
- 🔍 404 – Not found
- ⚠️ 422 – Invalid input
- 🔥 500 – Server crash
Using the right status code makes your API easier to debug, test, and integrate with.
💡 Examples
Example 1: 200 OK – Successful request with data
Used when you want to return data successfully:
def show
user = User.find(params[:id])
render json: user, status: :ok
end
Example 2: 201 Created – New item was saved
Used when a new resource is created:
def create
post = Post.create!(post_params)
render json: post, status: :created
end
Example 3: 204 No Content – Successfully deleted
Used when the request was successful but there’s nothing to return (like DELETE):
def destroy
comment = Comment.find(params[:id])
comment.destroy
head :no_content
end
Example 4: 404 Not Found – Resource doesn’t exist
Used when an item can’t be found:
def show
user = User.find_by(id: params[:id])
if user
render json: user
else
render json: { error: "User not found" }, status: :not_found
end
end
Example 5: 422 Unprocessable Entity – Validation failed
Used when form data is wrong or missing required fields:
def create
user = User.new(user_params)
if user.save
render json: user, status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
Example 6: 500 Internal Server Error – Server crashed
Used when something unexpected goes wrong (like a bug or system failure):
def risky_action
begin
# something risky
rescue => e
logger.error e.message
render json: { error: "Server error" }, status: :internal_server_error
end
end
🔁 Alternatives
- Use
head :ok
for 200 if no body is needed. - Use
render json:
with custom error messages for 404 or 422. - Use
status: numeric_code
as an alternative to symbol (e.g.,status: 422
).
❓ General Questions & Answers
Q1: What’s the difference between 200 and 201?
A: 200
means “Success – here’s your data.” It’s used when you’re fetching or updating something.
201
means “Created.” It’s used right after you create something new, like a user or post. It tells the client: “This was saved successfully.”
Q2: Why use 204 instead of 200?
A: 204 No Content
means the request worked, but there’s nothing to return (like when you delete something). It avoids sending an empty JSON object back unnecessarily.
Q3: What causes a 404 error?
A: A 404 Not Found
happens when you try to fetch a resource (like a user or post) that doesn’t exist in the database or the route doesn’t match anything.
Q4: What is 422 used for in Rails?
A: 422 Unprocessable Entity
means the request was fine, but the data sent was invalid. It’s perfect for failed validations (like a blank email or password too short).
Q5: When should I return a 500 error?
A: 500 Internal Server Error
is used when something breaks unexpectedly on the server — like a bug, exception, or database issue. You should handle these with rescue blocks and logging.
Q6: Should I always include a status in render
?
A: You don’t have to, but it’s a best practice — especially for APIs. It makes your responses more predictable and frontend-friendly.
# Better than default
render json: @user, status: :ok
🛠️ Deep Dive Technical Q&A
Q1: What is the default status code in Rails if none is specified?
A: Rails uses 200 OK
by default when you use render json:
without a status. This means the request worked, and data was returned.
render json: @user
# Implicitly returns 200 OK
Q2: How do I explicitly return a 201 Created?
A: Use status: :created
after creating a new resource:
render json: @post, status: :created
# Sends 201 Created with post JSON
Q3: When should I use head :no_content
instead of render json: {}
?
A: Use head :no_content
for true empty responses (like successful DELETE). It sends a 204 status with zero response body — saving bandwidth.
def destroy
Task.find(params[:id]).destroy
head :no_content
end
Q4: Can I return custom error messages with 404 or 422?
A: Yes. You can send both the status and a helpful JSON message:
render json: { error: "User not found" }, status: :not_found
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
Q5: Can I use numeric status codes instead of symbols?
A: Yes. Rails accepts both formats:
status: 404 # Numeric
status: :not_found # Symbol (preferred for readability)
Q6: How do I return a 500 Internal Server Error manually?
A: You can rescue an exception and respond like this:
rescue => e
logger.error e.message
render json: { error: "Server error" }, status: :internal_server_error
end
✅ Best Practices with Examples
1. Always include a status code when rendering JSON
Don’t rely on Rails’ default. Explicitly set status codes to make your API more readable and testable.
# Good
render json: @user, status: :ok
render json: @post, status: :created
# Avoid
render json: @user # implicit 200
2. Use 204 No Content
for DELETE actions
When a resource is deleted and nothing needs to be returned, send 204.
def destroy
Post.find(params[:id]).destroy
head :no_content
end
3. Return 422
for validation errors
This is the standard for when the request is valid, but the data is not (e.g. missing fields).
if user.save
render json: user, status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
4. Return 404
for missing resources
Instead of silently failing or returning an empty object, return a clear 404 with an error message.
user = User.find_by(id: params[:id])
if user
render json: user
else
render json: { error: "User not found" }, status: :not_found
end
5. Use 500
to handle unexpected errors
Don’t expose technical details — just return a generic message and log the issue internally.
rescue => e
logger.error e.message
render json: { error: "Something went wrong" }, status: :internal_server_error
end
6. Match HTTP verbs with appropriate status codes
GET
→200 OK
POST
→201 Created
DELETE
→204 No Content
PATCH/PUT
→200 OK
7. Avoid using 200
for every response
Status codes are meant to communicate intent. Use the most accurate one based on the outcome, not just “OK” for everything.
🌍 Real-world Scenario
Imagine you’re building a task management API (like Trello or Todoist). Here’s how you might use status codes in real endpoints:
-
✅ GET /tasks
The frontend asks for all tasks. The API returns the data with200 OK
.def index tasks = Task.all render json: tasks, status: :ok end
-
🆕 POST /tasks
The user creates a new task. The API returns the task JSON with201 Created
.def create task = Task.create!(task_params) render json: task, status: :created end
-
🗑️ DELETE /tasks/:id
A task is deleted. Nothing is returned, just a204 No Content
response.def destroy Task.find(params[:id]).destroy head :no_content end
-
🔍 GET /tasks/:id for a non-existing task
The frontend asks for a task that doesn’t exist. The API returns404 Not Found
.task = Task.find_by(id: params[:id]) if task render json: task else render json: { error: "Task not found" }, status: :not_found end
-
⚠️ POST /tasks with missing title
The frontend sends a task with no title. Rails validation fails, so we return422 Unprocessable Entity
.task = Task.new(task_params) if task.save render json: task, status: :created else render json: { errors: task.errors.full_messages }, status: :unprocessable_entity end
-
🔥 GET /tasks/random crashes unexpectedly
A bug or crash occurs. You rescue the error and return a500 Internal Server Error
.def random raise "Boom!" rescue => e logger.error e.message render json: { error: "Something went wrong" }, status: :internal_server_error end
✅ Using the correct status codes keeps your API clean, helps the frontend know what happened, and makes debugging easier.
to_json (Basic)
🧠 Detailed Explanation
In Rails, to_json
is a method you can use to change Ruby data (like a user or a list of users) into JSON — which is the format frontend apps, APIs, or JavaScript understand.
💡 Think of it like this: you’re taking a Ruby object and saying, “Turn this into a plain text string that looks like JSON.”
✅ For example, if you have this Ruby object:
user = User.new(id: 1, name: "Ali")
And you run:
user.to_json
➡️ It returns:
"{\"id\":1,\"name\":\"Ali\"}"
That’s JSON — the format that frontend apps or external tools like Postman can read.
🧠 Why use it?
You might use to_json
if you’re:
- Exporting data (like downloading JSON from the admin panel)
- Logging data in a readable format
- Debugging or previewing output in the console
📌 In APIs and controllers, Rails already handles JSON automatically with render json:
. But to_json
is great when you want full manual control.
💡 Examples
Example 1: Convert a single user to JSON
user = User.first
puts user.to_json
✅ This will return something like:
"{\"id\":1,\"name\":\"Ali\",\"email\":\"ali@example.com\", ...}"
You can use this in debugging, exporting, or sending data manually.
Example 2: Convert an array of users to JSON
users = User.all
puts users.to_json
✅ This returns an array of users in JSON format — useful for exporting a list of users.
Example 3: Only return specific fields
user.to_json(only: [:id, :email])
✅ This returns only the id
and email
in the JSON string. Good for avoiding sensitive data.
Example 4: Exclude some fields
user.to_json(except: [:created_at, :updated_at])
✅ This will return everything except timestamps. It keeps the response clean.
Example 5: Include associations (like posts)
user.to_json(include: :posts)
✅ This includes the user and their related posts in one JSON output.
Example 6: Nested include with selected fields
user.to_json(
include: {
posts: { only: [:title, :created_at] }
},
only: [:id, :name]
)
✅ This gives you total control over the structure — great for custom exports or sending data to external services.
🔁 Alternatives
as_json
– returns a Ruby hash instead of a stringrender json: object
– most common in controllers- Use
ActiveModel::Serializer
orJbuilder
for more structured APIs
❓ General Questions & Answers
Q1: What does to_json
do?
A: It turns a Ruby object (like a model, array, or hash) into a JSON string. JSON is a common data format used by frontend apps, APIs, and external services.
user = User.first
user.to_json
# => "{\"id\":1,\"name\":\"Ali\"}"
Q2: How is to_json
different from as_json
?
A: as_json
returns a Ruby hash, while to_json
turns that hash into a string. So as_json
is better for manipulating data before rendering, and to_json
is for final output.
Q3: Should I use to_json
in controllers?
A: Not usually. In controllers, it’s better to use render json: @user
— Rails handles the JSON formatting and content-type headers for you.
Q4: Can I control which fields show up in the JSON?
A: Yes! You can use only
or except
to include or exclude specific fields.
user.to_json(only: [:id, :email])
user.to_json(except: [:password_digest])
Q5: Can I include related models like posts or comments?
A: Yes! You can use the include:
option to embed associations inside the JSON output.
user.to_json(include: :posts)
Q6: Where is to_json
commonly used?
A: It’s often used in:
- Background jobs that need to save JSON files
- Exporting data (admin panels, reports)
- Sending structured data to third-party APIs
- Debugging complex objects in the console
🛠️ Deep Dive Technical Q&A
Q1: What type does to_json
return?
A: It returns a String
. If you call it on a model, array, or hash, the result is a plain JSON-formatted string that you can write to files, return from a service, or log.
user = User.first
puts user.to_json.class # => String
Q2: What’s the difference between as_json
and to_json
?
A: as_json
gives you a Ruby Hash (still structured data), while to_json
gives you a serialized String.
user.as_json # => { id: 1, name: "Ali" }
user.to_json # => "{\"id\":1,\"name\":\"Ali\"}"
Q3: Can I include nested associations with to_json
?
A: Yes! You can use the include:
option to embed associations like posts, comments, or profiles.
user.to_json(include: { posts: { only: [:title, :created_at] } })
Q4: How do I limit fields in the JSON output?
A: Use only:
to specify which fields you want, or except:
to leave some out.
user.to_json(only: [:id, :email])
user.to_json(except: [:password_digest, :created_at])
Q5: Can I format or modify the JSON structure before calling to_json
?
A: Yes — you can use as_json
to customize the hash first, then call to_json
on that result.
user_data = user.as_json(only: [:id, :email])
custom_data = user_data.merge(admin: true)
custom_data.to_json
Q6: Can I use to_json
with non-ActiveRecord objects?
A: Absolutely! Any Ruby object (array, hash, custom class) can be converted to JSON as long as it supports to_json
or as_json
.
[1, 2, 3].to_json # => "[1,2,3]"
{ name: "Ali" }.to_json # => "{\"name\":\"Ali\"}"
✅ Best Practices with Examples
1. Use to_json
for manual formatting, not in controllers
In Rails controllers, you should use render json:
instead of manually calling to_json
.
# ✅ Best in controllers
render json: @user
# ❌ Not recommended
render plain: @user.to_json
2. Use only:
or except:
to control output
Don’t expose sensitive or unnecessary data in your JSON.
user.to_json(only: [:id, :email])
user.to_json(except: [:password_digest])
3. Use include:
to add associations
This is helpful when exporting or showing relationships like posts, comments, or profile details.
user.to_json(include: :posts)
4. Use as_json
to customize structure before serializing
This is especially useful when adding virtual fields or combining multiple objects.
user_hash = user.as_json(only: [:id, :email])
user_hash[:role] = "admin"
user_hash.to_json
5. Avoid calling to_json
inside controller logic unless needed
Use it for exporting, background jobs, or debugging — not as your main response system.
6. Always format to_json
output before writing to files or external APIs
This ensures the output only contains the needed fields and avoids exposing internal structure.
7. Use serializers or Jbuilder for more complex formatting
When JSON becomes more than a few nested keys deep, prefer ActiveModel::Serializer
or Jbuilder
.
🌍 Real-world Scenario
Imagine you’re building an admin dashboard where admins can download a list of users as a JSON file for audit or import into another system.
Here’s how you might implement it in a Rails service or background job:
# app/services/export_users_service.rb
class ExportUsersService
def call
users = User.select(:id, :email, :created_at)
File.write("exported_users.json", users.to_json)
end
end
🧾 This method selects only the fields you want, converts them to JSON, and saves them to a file that admins can download later.
✅ Why use to_json
here?
- You’re not inside a controller — so
render json:
doesn’t apply. - You need a plain string to write to a file.
- You want full control over what gets included or excluded.
📦 You could also send that JSON to an external API like:
HTTP.post("https://api.example.com/sync", body: users.to_json)
✅ This makes to_json
a powerful tool outside the standard request-response cycle — especially in services, background jobs, and integrations.
as_json (Custom Serialization)
🧠 Detailed Explanation
In Rails, as_json
is a method that turns your Ruby objects (like a user, post, or array) into a plain Ruby Hash — not a string.
This is useful when you want to:
- Prepare JSON data before sending it to the frontend or an external API
- Control exactly which fields to include or remove
- Include custom or calculated fields (like
full_name
orstatus
)
You can then turn that hash into a string using to_json
, or simply return it using render json:
(Rails will call as_json
for you).
✅ Simple Example:
user = User.first
user.as_json
# => { "id"=>1, "name"=>"Ali", "email"=>"ali@example.com", ... }
You can also customize the output:
user.as_json(only: [:id, :email])
# => { "id"=>1, "email"=>"ali@example.com" }
💡 Think of as_json
as a flexible tool that lets you shape the data before it becomes JSON. This is especially helpful in APIs, background jobs, exports, and data formatting tasks.
🧠 Summary:
to_json
= turns an object into a JSON stringas_json
= turns an object into a Ruby hash that looks like JSON
💡 Examples
Example 1: Convert a single user to a Ruby-style JSON hash
user = User.first
user.as_json
# => { "id" => 1, "name" => "Ali", "email" => "ali@example.com", ... }
✅ This creates a Ruby hash that looks like JSON. You can now modify or inspect this hash.
Example 2: Only include specific fields
user.as_json(only: [:id, :email])
# => { "id" => 1, "email" => "ali@example.com" }
✅ This is helpful when you don’t want to show everything — only the essential info.
Example 3: Exclude unwanted fields
user.as_json(except: [:created_at, :updated_at])
✅ This removes timestamps or sensitive data like password_digest
.
Example 4: Include related models (associations)
user.as_json(include: :posts)
✅ This returns the user with all their posts included in the same JSON hash.
Example 5: Include nested associations with limited fields
user.as_json(include: {
posts: { only: [:title, :created_at] }
})
✅ You can control exactly what shows up from related models too.
Example 6: Add a custom field manually
json = user.as_json(only: [:id, :email])
json[:role] = "admin"
✅ This lets you insert extra fields dynamically into the hash before sending it out or converting to JSON.
Example 7: Add virtual methods (like full_name
)
user.as_json(methods: [:full_name])
# Assumes your model has:
# def full_name
# "#{first_name} #{last_name}"
# end
✅ This is great for including logic that isn’t a database column.
🔁 Alternatives
to_json
– directly converts to string, less flexible for adding logicActiveModel::Serializer
– full control with dedicated serializer classesJbuilder
– useful for view-based JSON formatting
❓ General Questions & Answers
Q1: What does as_json
actually return?
A: It returns a plain Ruby hash that looks like JSON. It doesn’t convert it to a string — it just gives you a data structure you can modify or use directly with render json:
.
user.as_json
# => { "id"=>1, "name"=>"Ali", "email"=>"ali@example.com" }
Q2: What’s the difference between as_json
and to_json
?
A:
as_json
returns a Ruby hashto_json
returns a JSON string
💡 Use as_json
when you want to customize or inspect the data first.
Q3: Can I use as_json
in a controller?
A: Yes, but it’s often not necessary. When you use render json: object
, Rails automatically calls as_json
on that object behind the scenes.
render json: user # Rails does user.as_json for you
Q4: How do I hide sensitive fields like passwords?
A: Use except:
to exclude them:
user.as_json(except: [:password_digest])
Q5: Can I include custom fields like a user role or full name?
A: Yes! You can use the methods:
option or merge extra values manually:
user.as_json(methods: [:full_name])
# or
user.as_json.merge(role: "admin")
Q6: When should I use as_json
over serializers or views?
A: Use as_json
when:
- You want a quick way to prepare structured data
- You’re exporting or integrating with other systems
- You don’t need complex reusable formatting logic
🛠️ Deep Dive Technical Q&A
Q1: Can I customize as_json
for a model?
A: Yes. You can override as_json
in your model to control the default serialization globally.
class User < ApplicationRecord
def as_json(options = {})
super({ only: [:id, :email] }.merge(options))
end
end
✅ This way, any time you call user.as_json
, it will only include id
and email
.
Q2: Can I include computed/virtual fields like full_name
?
A: Yes! You can use the methods:
option in as_json
to include methods from your model.
user.as_json(methods: [:full_name])
✅ Make sure full_name
is defined in your model.
Q3: Can I include nested associations with custom fields?
A: Absolutely. Use a nested hash under the include:
option:
user.as_json(include: {
posts: {
only: [:title, :created_at]
}
})
✅ This gives you fine-grained control over what’s returned from each related model.
Q4: Does as_json
work with arrays?
A: Yes. If you call it on a collection like User.all
, it will call as_json
on each record.
users = User.all
users.as_json(only: [:id, :email])
Q5: How do I convert the output to a real JSON string?
A: Call to_json
after as_json
if you need a JSON-formatted string:
user.as_json(only: [:id]).to_json
Q6: Can as_json
be used with non-ActiveRecord objects?
A: Yes! Any Ruby object that implements as_json
(including arrays and hashes) can use it — and you can define it yourself in custom classes.
class Book
def as_json(*)
{ title: "Rails Mastery", author: "Ali" }
end
end
Book.new.as_json
# => { title: "Rails Mastery", author: "Ali" }
✅ Best Practices with Examples
1. Use only
or except
to limit fields
This keeps your responses lightweight and secure by excluding unnecessary or sensitive data.
user.as_json(only: [:id, :email])
user.as_json(except: [:password_digest, :updated_at])
2. Use methods:
to include custom logic
Include virtual fields like full_name
or status helpers using methods defined on the model.
user.as_json(methods: [:full_name, :active_status])
3. Use include:
for associations (carefully)
Only include nested data when needed, and narrow down fields using only:
.
user.as_json(include: { posts: { only: [:title] } })
4. Merge extra values when building dynamic JSON manually
Combine as_json
with extra values based on business logic.
json = user.as_json(only: [:id, :email])
json[:is_admin] = current_user.admin?
5. Override as_json
in the model for reusable global formatting
This ensures consistent API responses across your app.
class User < ApplicationRecord
def as_json(options = {})
super({ only: [:id, :email], methods: [:full_name] }.merge(options))
end
end
6. Avoid exposing raw as_json
directly to users in views
It’s better suited for APIs, export jobs, or internal formatting than for rendering in ERB/HTML.
7. Prefer serializers (like ActiveModel::Serializer or Jbuilder) for complex APIs
When your data structure grows beyond simple hashes, use serializers for better structure and reusability.
🌍 Real-world Scenario
Imagine you’re building a feature in your Rails app that allows an admin to export user data to a third-party analytics platform.
You don’t want to send all user fields (like passwords or timestamps), and you want to include custom logic like full name and account status.
✅ Here’s how you might do it with as_json
:
# app/services/export_user_data_service.rb
class ExportUserDataService
def call
users = User.where(active: true)
users.map do |user|
user.as_json(
only: [:id, :email],
methods: [:full_name, :status_label]
)
end.to_json
end
end
📦 This would return something like:
[
{
"id": 1,
"email": "ali@example.com",
"full_name": "Ali Ahmed",
"status_label": "Active"
},
...
]
✅ Now this JSON can be:
- Downloaded by the admin
- Sent to an external analytics tool
- Logged for auditing
🧠 Why this works well:
- You only include the fields that matter
- You add custom logic (
full_name
,status_label
) - You get full control of the JSON without needing a serializer
Jbuilder (default Rails templating)
🧠 Detailed Explanation
Jbuilder is a tool in Rails that helps you write JSON responses using Ruby code. Instead of building JSON strings by hand, Jbuilder lets you create them in a readable and flexible way — similar to how ERB templates work for HTML.
You use it in views like this:
app/views/users/show.json.jbuilder
Inside this file, you can use Ruby to write what the JSON should look like:
json.id @user.id
json.name @user.name
json.email @user.email
🔄 Jbuilder will convert that into:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com"
}
✅ It’s especially useful for:
- Customizing the shape of JSON data
- Building nested JSON (like posts inside users)
- Using Ruby logic like
if
,each
, ordo/end
💡 You don’t have to convert objects manually with as_json
or to_json
— just use Jbuilder in your views and let Rails render the response.
📦 By default, new Rails apps come with Jbuilder already set up. Just create a view file ending in .json.jbuilder
, and Rails will render it when your controller responds with format.json
.
📦 Full Implementation: Backend + Jbuilder + UI Preview
This walkthrough shows how to build a Rails API using Jbuilder and how to view the JSON in a simple UI inside your Rails app or via Postman.
Step 1: Ensure Jbuilder is Installed
# Gemfile
gem 'jbuilder'
Run bundle install
if it’s not already installed.
Step 2: Create Models
rails g model User name:string
rails g model Post title:string content:text user:references
rails g model Comment body:text user:references post:references
rails db:migrate
Step 3: Create Controller with JSON Response
# app/controllers/api/posts_controller.rb
class Api::PostsController < ApplicationController
def show
@post = Post.includes(:comments, :user).find(params[:id])
respond_to do |format|
format.json
format.html # for browser UI preview
end
end
end
Step 4: Jbuilder JSON View
app/views/api/posts/show.json.jbuilder
json.id @post.id
json.title @post.title
json.content @post.content
json.author do
json.id @post.user.id
json.name @post.user.name
end
json.comments @post.comments, partial: "api/comments/comment", as: :comment
Step 5: Create Comment Partial
app/views/api/comments/_comment.json.jbuilder
json.id comment.id
json.body comment.body
json.user do
json.id comment.user.id
json.name comment.user.name
end
Step 6: Optional HTML UI Preview
To preview your API JSON in a styled browser UI, you can add a simple view:
app/views/api/posts/show.html.erb
<h2>📦 Post JSON Response Preview</h2>
<pre style="background: #111; color: #0f0; padding: 20px; border-radius: 10px;">
<%= JSON.pretty_generate(@post.as_json(include: {
user: { only: [:id, :name] },
comments: {
include: { user: { only: [:id, :name] } },
only: [:id, :body]
}
})) %>
</pre>
✅ Now you can view /api/posts/:id.json
for raw JSON and /api/posts/:id.html
for a styled HTML preview.
Step 7: Routes Setup
# config/routes.rb
namespace :api do
resources :posts, only: [:show]
end
Resulting JSON Output:
{
"id": 1,
"title": "Mastering Jbuilder",
"content": "This is how Jbuilder works...",
"author": {
"id": 2,
"name": "Ali"
},
"comments": [
{
"id": 1,
"body": "Great article!",
"user": {
"id": 3,
"name": "Sara"
}
}
]
}
✅ This setup is clean, scalable, DRY, and frontend-ready.
💡 Examples
Example 1: Basic user JSON
Create a Jbuilder file: app/views/users/show.json.jbuilder
json.id @user.id
json.name @user.name
json.email @user.email
✅ Output:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com"
}
Example 2: Add nested resource (e.g., profile)
json.profile do
json.bio @user.profile.bio
json.location @user.profile.location
end
✅ Output:
"profile": {
"bio": "Full-stack developer",
"location": "Lahore"
}
Example 3: Render an array of users
app/views/users/index.json.jbuilder
json.array! @users do |user|
json.id user.id
json.name user.name
end
✅ Output:
[
{ "id": 1, "name": "Ali" },
{ "id": 2, "name": "Sara" }
]
Example 4: Using partials for clean structure
json.posts @user.posts, partial: "posts/post", as: :post
✅ This will render each post using: _post.json.jbuilder
Example 5: Add conditionals
json.name @user.name
json.admin true if @user.admin?
✅ This adds "admin": true
only if the user is an admin.
🔁 Alternatives
as_json
– Basic Ruby-level customization of hashActiveModel::Serializer
– Object-oriented serializer classto_json
– Converts data directly to a JSON string
❓ General Questions & Answers
Q1: What is Jbuilder used for?
A: Jbuilder is a Rails tool that helps you create structured JSON using Ruby code. It’s useful for building JSON responses in API views without manually writing strings or hashes.
Q2: Where do Jbuilder files live?
A: Inside the app/views
folder, using the pattern controller_name/action.json.jbuilder
.
For example: app/views/posts/show.json.jbuilder
Q3: Is Jbuilder included in new Rails apps by default?
A: Yes. It’s included in the Gemfile and works out of the box unless you manually remove it.
Q4: Can I use Ruby logic inside Jbuilder?
A: Yes! You can use loops, if
statements, helpers, and even partials — just like in regular ERB views.
Q5: How is Jbuilder different from render json:
or as_json
?
A:
render json:
automatically converts objects usingas_json
as_json
gives you a plain Ruby hashJbuilder
gives you a clean, structured, Ruby-based JSON view template
Q6: Can I render arrays or nested JSON with Jbuilder?
A: Absolutely! Use json.array!
for arrays, and json.object
or json.partial
for nesting structures and reusable templates.
🛠️ Deep Dive Technical Q&A
Q1: How does Rails know to use a .json.jbuilder
file?
A: Rails uses the `respond_to` block in the controller. If the request format is JSON, it will look for a matching view like show.json.jbuilder
.
def show
@user = User.find(params[:id])
respond_to do |format|
format.json # → looks for views/users/show.json.jbuilder
end
end
Q2: How do I render an array of records in Jbuilder?
A: Use json.array!
with a block to loop over items.
json.array! @users do |user|
json.id user.id
json.email user.email
end
Q3: How do I reuse JSON structures with partials?
A: Use json.partial!
with the path to the partial and an alias:
json.users @users, partial: "users/user", as: :user
This will use _user.json.jbuilder
for each object.
Q4: Can I conditionally add fields in Jbuilder?
A: Yes, use regular Ruby logic:
json.email @user.email if current_user.admin?
Q5: Can I render nested models?
A: Yes. You can nest json
blocks or use include:
via partials.
json.comments @post.comments, partial: "comments/comment", as: :comment
Q6: Can I pass local variables to Jbuilder views?
A: Yes! When using render
, you can pass locals like this:
render partial: "users/user", locals: { user: @user }
Q7: What happens if I call render json:
in a controller and also have a Jbuilder template?
A: If you use render json:
, Rails bypasses the view and directly returns the object. If you want to use the Jbuilder view, use format.json
instead and avoid passing the object manually.
✅ Best Practices with Examples
1. Keep Jbuilder files small and focused
Only include the data needed for that specific action. Use partials for reusable pieces like users or comments.
# Good
json.partial! "users/user", user: @user
# Avoid large, complex, deeply nested structures in one file
2. Use partials for repeated structures
This keeps your views organized and DRY (Don’t Repeat Yourself).
json.posts @user.posts, partial: "posts/post", as: :post
3. Use conditionals to control data visibility
Don’t expose sensitive data to unauthorized users.
json.email @user.email if current_user.admin?
4. Avoid putting complex business logic inside Jbuilder files
Move that logic into model methods or helpers.
# In model
def full_name
"#{first_name} #{last_name}"
end
# In Jbuilder
json.full_name @user.full_name
5. Use json.array!
for custom collections
This gives you full control over how collections are rendered.
json.array! @users do |user|
json.id user.id
json.name user.name
end
6. Let controllers handle respond_to
logic
Keep views focused on formatting — not choosing formats.
respond_to do |format|
format.json
end
7. Use Jbuilder when you want control and readability
Don’t use to_json
or as_json
if you need a readable, maintainable JSON response with nested resources.
🌍 Real-world Scenario
Let’s say you’re building a blog platform with a RESTful Rails API. The frontend (like React or a mobile app) calls /api/posts/:id
to fetch a blog post and its comments.
Here’s what the frontend expects:
{
"id": 1,
"title": "How to use Jbuilder",
"content": "Jbuilder lets you...",
"author": {
"id": 4,
"name": "Ali Ahmed"
},
"comments": [
{
"id": 1,
"body": "Great post!",
"user": { "id": 2, "name": "Sara" }
},
...
]
}
🔧 You can build this using Jbuilder like this:
# app/views/posts/show.json.jbuilder
json.id @post.id
json.title @post.title
json.content @post.content
json.author do
json.id @post.user.id
json.name @post.user.name
end
json.comments @post.comments, partial: "comments/comment", as: :comment
And your comment partial:
# app/views/comments/_comment.json.jbuilder
json.id comment.id
json.body comment.body
json.user do
json.id comment.user.id
json.name comment.user.name
end
✅ This structure keeps your API clean, scalable, and easy to consume by frontend developers — with full flexibility.
📦 You can also use this same pattern for exporting data, building admin dashboards, or syncing with external services.
ActiveModel::Serializer (AMS)
🧠 Detailed Explanation
ActiveModel::Serializer (AMS) is a Ruby gem used in Rails APIs to build and customize JSON responses using Ruby classes called serializers.
Normally, when you return JSON from Rails, you might use:
render json: @user
This will return all fields — even ones you don’t want to expose, like password_digest
.
With AMS, you can tell Rails exactly what fields you want to return — and what associations (like posts, comments, etc.) to include.
✅ How it works:
- You create a
UserSerializer
class - You list which fields should go in the JSON
- Optionally, you define
has_many
,belongs_to
, or custom fields likepost_count
When you write render json: @user
in your controller, Rails automatically uses the UserSerializer
to format the JSON.
💡 Why use AMS?
- ✅ Keeps your controller clean
- ✅ Keeps your JSON output consistent
- ✅ Reusable across multiple places (show, index, API, mobile)
- ✅ Easy to test & easy to scale
💭 Think of AMS like a blueprint — it says “when I send a user to the frontend, here’s what it should look like.”
👨💻 For example:
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email
has_many :posts
end
And now your JSON output will only include the specified fields and associations.
📦 Best Implementation: ActiveModel::Serializer (AMS)
This guide walks through how to use AMS to cleanly serialize data in a Rails API.
Step 1: Add the AMS gem
# Gemfile
gem 'active_model_serializers', '~> 0.10.0'
Then run:
bundle install
Step 2: Generate models
rails g model User name:string email:string
rails g model Post title:string content:text user:references
rails db:migrate
Step 3: Create controller
# app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
def show
@user = User.find(params[:id])
render json: @user
end
end
✅ This will automatically use UserSerializer
when you call render json:
.
Step 4: Generate serializers
rails g serializer User
rails g serializer Post
Step 5: Define UserSerializer
app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email, :post_count
has_many :posts
def post_count
object.posts.count
end
end
Step 6: Define PostSerializer
app/serializers/post_serializer.rb
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :created_at
end
Step 7: Setup routes
# config/routes.rb
namespace :api do
resources :users, only: [:show]
end
Step 8: Visit your API endpoint
GET http://localhost:3000/api/users/1
✅ JSON Output Example:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com",
"post_count": 2,
"posts": [
{
"id": 1,
"title": "Hello AMS",
"content": "This post is serialized.",
"created_at": "2025-04-15T06:00:00Z"
},
...
]
}
🧠 Summary:
render json: @model
→ AMS auto-finds the serializerattributes
→ Define fields to exposehas_many / belongs_to
→ Nest associations automaticallycustom methods
→ Add computed fields likepost_count
✅ Use AMS when you want reusable, testable, and consistent JSON structure — especially helpful for APIs consumed by mobile or frontend teams.
💡 Examples
Example 1: Basic User Serializer
app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email
end
✅ Output for render json: @user
:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com"
}
Example 2: Add a custom/computed field
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email, :post_count
def post_count
object.posts.count
end
end
✅ Adds a dynamic value to the response:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com",
"post_count": 4
}
Example 3: Include associated records
class UserSerializer < ActiveModel::Serializer
attributes :id, :name
has_many :posts
end
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :created_at
end
✅ Output will include nested posts:
{
"id": 1,
"name": "Ali",
"posts": [
{ "id": 10, "title": "Hello World", "created_at": "..." },
...
]
}
Example 4: Serialize a collection (index)
Controller:
def index
@users = User.all
render json: @users
end
✅ AMS will automatically apply UserSerializer
to each record:
[
{ "id": 1, "name": "Ali", "email": "ali@example.com" },
{ "id": 2, "name": "Sara", "email": "sara@example.com" }
]
🔁 Alternatives
Jbuilder
– Use views to build JSON responsesas_json
– Manual inline hash controlto_json
– Fast string-based JSON generation (not flexible)
❓ General Questions & Answers
Q1: What is ActiveModel::Serializer?
A: It’s a Rails gem that helps you define how your model data should be converted to JSON. Instead of returning everything, you control what’s shown in the API response.
Q2: Is AMS included by default in Rails?
A: No — it’s not included by default. You need to add it manually to your Gemfile
:
gem 'active_model_serializers', '~> 0.10.0'
Q3: Do I need a serializer for every model?
A: Only if you want custom control over how that model’s data is shown in JSON. Rails will still return basic JSON if a serializer is not defined, but it won’t be customizable.
Q4: How does Rails know which serializer to use?
A: When you write render json: @user
, Rails will look for a UserSerializer
and automatically use it if AMS is installed.
Q5: Can I use serializers with relationships like has_many
?
A: Yes! You can add has_many
or belongs_to
inside your serializer, and it will include nested serialized data.
Q6: How is AMS different from Jbuilder?
A:
- AMS: Uses Ruby classes to define structure
- Jbuilder: Uses view templates (
.json.jbuilder
) to build JSON
Q7: Can I test my serializers?
A: Yes. Because serializers are plain Ruby classes, you can write unit tests for them using RSpec or Minitest.
🛠️ Deep Dive Technical Q&A
Q1: Can I include virtual/computed attributes in a serializer?
A: Yes! Just define a method inside your serializer and include it in the attributes
list.
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :account_age
def account_age
(Time.zone.now - object.created_at).to_i / 1.day
end
end
Q2: How do I serialize associations?
A: Use has_many
or belongs_to
in your serializer, and AMS will automatically use the related serializer.
class PostSerializer < ActiveModel::Serializer
attributes :id, :title
belongs_to :user
has_many :comments
end
Q3: Can I limit nested data (e.g., only some fields in an association)?
A: Yes — create a dedicated serializer for that model and only include the needed fields.
# app/serializers/comment_serializer.rb
class CommentSerializer < ActiveModel::Serializer
attributes :id, :body
end
Q4: Can I conditionally show attributes?
A: Yes. Use object
and custom logic in your method:
attributes :id, :email, :is_admin
def is_admin
current_user.admin? if scope == object
end
Q5: Can I customize the root key of my JSON?
A: Yes. In an initializer, you can disable or rename the root:
# config/initializers/active_model_serializers.rb
ActiveModelSerializers.config.adapter = :json
ActiveModelSerializers.config.default_includes = '**'
To disable root altogether:
ActiveModelSerializers.config.adapter = :attributes
Q6: Can I override the serialization logic entirely?
A: Yes! AMS is just Ruby. You can override any method or structure as needed.
def attributes(*args)
data = super
data[:custom_field] = "Injected manually"
data
end
Q7: Does AMS support pagination?
A: Yes. Use gems like kaminari
or will_paginate
and pass meta information manually.
render json: @posts, meta: { total: @posts.total_count }
✅ Best Practices with Examples
1. Keep your serializers thin and focused
Only include the fields necessary for that API response. Don’t expose internal or sensitive data.
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email
end
2. Use associations with has_many
/ belongs_to
This ensures related data is included in a consistent, structured way.
class PostSerializer < ActiveModel::Serializer
attributes :id, :title
belongs_to :user
end
3. Move business logic to model or helper methods — not serializer
Keep your serializers clean. Use methods only for formatting or lightweight custom fields.
# User model
def full_name
"#{first_name} #{last_name}"
end
# Serializer
class UserSerializer < ActiveModel::Serializer
attributes :id, :full_name
end
4. Use scope
to pass current_user for permission-based logic
You can customize output based on who’s accessing it.
def show_sensitive_data
scope.admin?
end
5. Use attributes
method to manipulate the response hash
This is useful if you want to inject or transform fields globally.
def attributes(*args)
data = super
data[:note] = "Read-only field"
data
end
6. Create specialized serializers for different API views
Don’t try to make one serializer fit all. Use custom serializers or options per endpoint.
# app/serializers/user_summary_serializer.rb
class UserSummarySerializer < ActiveModel::Serializer
attributes :id, :name
end
7. Use render json:
with collections and meta data for paginated results
Works well with kaminari
or will_paginate
.
render json: @users, meta: { total: @users.total_count }
🌍 Real-world Scenario
Imagine you’re building a backend for a social app. The frontend needs to show a user’s profile along with their latest posts.
When a frontend developer hits /api/users/1
, they expect this JSON response:
{
"id": 1,
"name": "Ali Ahmed",
"email": "ali@example.com",
"post_count": 2,
"posts": [
{
"id": 5,
"title": "Learning Rails API",
"created_at": "2025-04-15T07:00:00Z"
},
{
"id": 6,
"title": "Understanding ActiveModel::Serializer",
"created_at": "2025-04-15T08:00:00Z"
}
]
}
✅ Here’s how you would implement this using AMS:
# app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
def show
@user = User.find(params[:id])
render json: @user
end
end
UserSerializer:
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email, :post_count
has_many :posts
def post_count
object.posts.size
end
end
PostSerializer:
class PostSerializer < ActiveModel::Serializer
attributes :id, :title, :created_at
end
🎯 Now anytime @user
is rendered in a Rails controller, you get structured, nested, and customizable JSON — no manual formatting, no duplication, and full consistency across your app.
📱 This approach is especially useful when working with:
- Frontend frameworks (React, Vue, etc.)
- Mobile apps (iOS/Android)
- 3rd-party API integrations
Fast JSON API / Blueprinter / RABL
🧠 Detailed Explanation
When building APIs in Rails, you need to format your data into JSON. By default, Rails provides tools like to_json
, as_json
, Jbuilder
, and ActiveModel::Serializer
.
But if you’re working on:
- Large-scale APIs
- Mobile apps or microservices
- Performance-sensitive apps
That’s where these three libraries come in:
- Fast JSON API – Extremely fast. Based on the
JSON:API
spec. Great for large datasets. - Blueprinter – A lightweight, easy-to-read alternative to serializers. Flexible and clean syntax.
- RABL – Short for “Ruby API Builder Language.” Lets you build JSON using view-like templates. Ideal for older or templating-heavy systems.
Unlike Jbuilder (which renders JSON in views) or AMS (which adds logic inside serializers), these tools separate concerns clearly:
- Blueprints / Serializers live outside your models and views
- No extra logic in views or controllers
- More efficient and testable
✅ These libraries shine when:
- You want consistent performance
- You care about JSON structure and versioning
- You work with multiple API clients
Summary:
- Use Fast JSON API for big data, nested associations, and speed
- Use Blueprinter for clean, modular serializers that are easy to maintain
- Use RABL when you need templating logic and flexibility with legacy Rails views
📦 Step-by-Step Implementation: Fast JSON API / Blueprinter / RABL
This shows how to implement high-performance JSON serialization using Fast JSON API, Blueprinter, and RABL in Rails.
🚀 Fast JSON API Implementation
Step 1: Add the gem
# Gemfile
gem 'fast_jsonapi'
bundle install
Step 2: Create a serializer
# app/serializers/post_serializer.rb
class PostSerializer
include FastJsonapi::ObjectSerializer
attributes :id, :title, :content
belongs_to :user
end
Step 3: Use it in your controller
# app/controllers/api/posts_controller.rb
def show
post = Post.find(params[:id])
render json: PostSerializer.new(post).serializable_hash
end
JSON Output:
{
"data": {
"id": "1",
"type": "post",
"attributes": {
"title": "Hello Fast JSON",
"content": "This is fast!"
}
}
}
📘 Blueprinter Implementation
Step 1: Add the gem
# Gemfile
gem 'blueprinter'
bundle install
Step 2: Create a blueprint
# app/blueprints/post_blueprint.rb
class PostBlueprint < Blueprinter::Base
identifier :id
fields :title, :content
association :user, blueprint: UserBlueprint
end
# app/blueprints/user_blueprint.rb
class UserBlueprint < Blueprinter::Base
identifier :id
fields :name, :email
end
Step 3: Use it in the controller
# app/controllers/api/posts_controller.rb
def show
post = Post.includes(:user).find(params[:id])
render json: PostBlueprint.render(post)
end
JSON Output:
{
"id": 1,
"title": "Intro to Blueprinter",
"content": "Very fast and clean",
"user": {
"id": 3,
"name": "Ali",
"email": "ali@example.com"
}
}
🧾 RABL Implementation
Step 1: Add the gem
# Gemfile
gem 'rabl'
bundle install
Step 2: Enable RABL globally
# config/application.rb
config.generators do |g|
g.template_engine :rabl
end
Step 3: Create the RABL template
# app/views/posts/show.json.rabl
object @post
attributes :id, :title, :content
node(:author_name) { |p| p.user.name }
Step 4: In your controller
def show
@post = Post.find(params[:id])
respond_to do |format|
format.json # uses show.json.rabl
end
end
JSON Output:
{
"id": 1,
"title": "Using RABL in Rails",
"content": "Great for templating",
"author_name": "Ali"
}
✅ Summary
- Fast JSON API: Best for speed, large datasets, JSON:API spec
- Blueprinter: Clean, minimal, easy to maintain and test
- RABL: Good for legacy apps or detailed templating needs
📱 All three are great choices depending on your use case, team preference, and performance needs.
💡 Examples
🚀 Fast JSON API
Serializer:
# app/serializers/user_serializer.rb
class UserSerializer
include FastJsonapi::ObjectSerializer
attributes :id, :name, :email
has_many :posts
end
Controller:
render json: UserSerializer.new(@user).serializable_hash
Output:
{
"data": {
"id": "1",
"type": "user",
"attributes": {
"name": "Ali",
"email": "ali@example.com"
},
"relationships": {
"posts": {
"data": [{ "id": "3", "type": "post" }]
}
}
}
}
📘 Blueprinter
Blueprint:
# app/blueprints/user_blueprint.rb
class UserBlueprint < Blueprinter::Base
identifier :id
fields :name, :email
association :posts, blueprint: PostBlueprint
end
# app/blueprints/post_blueprint.rb
class PostBlueprint < Blueprinter::Base
identifier :id
fields :title
end
Controller:
render json: UserBlueprint.render(@user)
Output:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com",
"posts": [
{ "id": 3, "title": "Rails Performance" }
]
}
🧾 RABL
Template:
# app/views/users/show.json.rabl
object @user
attributes :id, :name, :email
child :posts do
attributes :id, :title
end
Controller:
def show
@user = User.find(params[:id])
respond_to do |format|
format.json # triggers show.json.rabl
end
end
Output:
{
"id": 1,
"name": "Ali",
"email": "ali@example.com",
"posts": [
{ "id": 3, "title": "RABL Is Still Alive!" }
]
}
🔁 Alternatives
Jbuilder
– Built-in, simple, slower for large payloadsAMS
– Standardized, readable, good for medium APIsto_json/as_json
– Basic manual serialization
❓ General Questions & Answers
Q1: Why would I use Fast JSON API instead of Jbuilder or AMS?
A: If you’re dealing with large datasets or want JSON:API spec support and faster response times, Fast JSON API is the best choice. It was built with performance in mind.
Q2: Is Blueprinter easier than AMS?
A: Yes. Blueprinter is simpler, more flexible, and easier to learn. It uses a “blueprint” concept which clearly separates serialization logic from models or views.
Q3: What is RABL used for today?
A: RABL is useful in projects that already rely on view-like templates and need XML or JSON output. It’s very customizable and works well in legacy apps, but is less popular in modern APIs.
Q4: Can I switch between these serializers later?
A: Yes, though the setup is different. If you keep rendering logic clean (inside serializers/blueprints only), switching is often just replacing the class and render call.
Q5: Which one supports JSON:API spec?
A: Only Fast JSON API is built around the JSON:API standard (including relationships, links, meta, pagination, etc.).
Q6: Are these faster than Jbuilder?
A: Yes — especially Fast JSON API and Blueprinter. Jbuilder renders templates line-by-line like views, which can be slower with large nested data.
Q7: Are they beginner-friendly?
A:
- Blueprinter – ✅ Very beginner-friendly
- Fast JSON API – 🧠 Easy, but uses JSON:API structure
- RABL – 🧾 Good if you’re used to view templates
🛠️ Deep Dive Technical Q&A
Q1: Can Fast JSON API include nested relationships?
A: Yes. Use has_many
or belongs_to
in your serializer, and pass include:
when rendering.
render json: PostSerializer.new(@post, include: [:user, :comments])
Q2: How do I render a collection with Blueprinter?
A: Just use render_collection
like this:
render json: PostBlueprint.render(posts)
Q3: How do I paginate with Fast JSON API?
A: Use your pagination gem (like kaminari
), and add pagination metadata manually:
render json: PostSerializer.new(@posts).serializable_hash,
meta: { total_pages: @posts.total_pages }
Q4: Can Blueprinter conditionally show fields?
A: Yes! Use field :name, if: ->(field, obj, opts) { ... }
field :email, if: ->(field, user, options) { options[:admin] }
Q5: Can RABL include custom logic?
A: Yes. You can use Ruby logic inside a RABL file (like if
, each
, etc.)
node(:is_admin) { |user| user.admin? }
Q6: Can I override JSON root or structure?
A:
- Fast JSON API: JSON:API format only
- Blueprinter: No enforced format — you design the structure
- RABL: Full control over JSON shape
Q7: How do I render related objects with custom serializers/blueprints?
A: Each library has its own method:
- Fast JSON API:
has_many :comments
+include: [:comments]
- Blueprinter:
association :comments, blueprint: CommentBlueprint
- RABL:
child :comments do ... end
✅ Best Practices with Examples
1. Choose the tool based on your project needs
- Use Fast JSON API if you follow JSON:API spec and care about speed
- Use Blueprinter for simpler APIs and readable syntax
- Use RABL in legacy or view-based JSON generation
2. Avoid putting business logic in serializers or blueprints
Keep computation-heavy logic inside models or service objects. Serializers should only shape data.
# ❌ Don't do this:
def balance
user.transactions.map(&:amount).sum
end
3. Reuse blueprints/serializers across views
Define one serializer per model and use fields
or views
to customize when needed.
UserBlueprint.render(@user, view: :public)
4. Use includes
or eager_load
in controllers to prevent N+1 queries
@posts = Post.includes(:user, :comments)
This makes serialization faster by avoiding repeated DB calls.
5. Use meta fields for pagination and status
render json: PostBlueprint.render(@posts),
meta: { page: 1, total_pages: 10 }
6. Test your serializers or blueprints
These are plain Ruby classes. You can unit test them with RSpec or Minitest.
expect(UserBlueprint.render(user)).to include_json(name: "Ali")
7. Use caching when serializing large datasets
Use Rails.cache
or cached
block in Blueprinter to avoid regenerating output for the same data.
cache key: ->(user) { "user/#{user.id}" }, expires_in: 15.minutes
8. Keep response payloads clean and predictable
Always return consistent key names and structures — especially if consumed by mobile or 3rd-party clients.
🌍 Real-world Scenario
You’re building a high-traffic Rails API that powers a mobile app with thousands of active users. The app shows user profiles, lists of posts, and allows commenting.
On each screen, users expect fast load times and consistent data formatting. Here’s how different tools fit in:
🚀 Fast JSON API Scenario
Your `/api/posts` endpoint must return hundreds of posts per request — each with a user and tags. You choose Fast JSON API to optimize speed and adopt the JSON:API format used by your frontend team.
class PostSerializer
include FastJsonapi::ObjectSerializer
attributes :id, :title, :content
belongs_to :user
has_many :tags
end
render json: PostSerializer.new(@posts, include: [:user, :tags])
✅ Response time drops from 150ms → 70ms, making the app noticeably faster.
📘 Blueprinter Scenario
You want to render multiple formats of user data depending on the context — full details for admins, minimal for public profiles. You use Blueprinter views to organize these.
class UserBlueprint < Blueprinter::Base
identifier :id
view :public do
fields :name
end
view :admin do
fields :name, :email, :role
end
end
render json: UserBlueprint.render(@user, view: :public)
✅ One blueprint, multiple views — no duplication and consistent output.
🧾 RABL Scenario
You’re maintaining a legacy API that uses RABL templates to support both JSON and XML exports of invoices.
# app/views/invoices/show.json.rabl
object @invoice
attributes :id, :total, :due_date
child :customer do
attributes :name, :email
end
✅ RABL provides structured formatting across formats without rewriting views.
📦 Summary:
- Fast JSON API: When speed + JSON:API format is key
- Blueprinter: When flexibility + maintainability matter
- RABL: When view-based rendering and format flexibility are required
Plain PORO Serializers (Custom Classes)
🧠 Detailed Explanation
A PORO Serializer is a Plain Old Ruby Object class used to manually define how your Ruby/Rails object should be converted into JSON.
Unlike libraries like Jbuilder, ActiveModel::Serializer, or Blueprinter, which offer built-in structure or templates, PORO serializers give you:
- ✅ Full control over the output
- ✅ No dependency on gems or conventions
- ✅ Maximum speed and flexibility
It’s just a regular Ruby class with a method like as_json
or to_h
that returns a hash — which Rails then converts into JSON.
Why use PORO serializers?
- 🔹 You want something fast with no gem overhead
- 🔹 You don’t want to follow rigid structures like JSON:API
- 🔹 You want to create your own format (for example, for a mobile app or third-party integration)
Think of it this way: instead of writing…
render json: @user
…and letting Rails choose what to show, you define:
render json: UserSerializer.new(@user).as_json
🔍 This makes it easier to:
- ✅ Avoid leaking sensitive fields
- ✅ Include only exactly what the frontend needs
- ✅ Reuse logic and create more testable code
When not to use PORO serializers:
- ❌ When you need standardized APIs like
JSON:API
- ❌ When you’re dealing with large teams who prefer standardized tooling
- ❌ When you need built-in pagination/meta-link handling
✅ In summary, PORO serializers are best when you want simplicity, speed, and full control — and are okay doing a bit of manual work.
📦 Step-by-Step Implementation
This method is great when you want full control over your JSON output without using any third-party gems.
Step 1: Create a plain Ruby class (serializer)
Make a new file in app/serializers/
or app/services/
(your choice):
# app/serializers/user_serializer.rb
class UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
name: @user.name,
email: @user.email,
post_count: @user.posts.size,
posts: @user.posts.map do |post|
{
id: post.id,
title: post.title,
comments_count: post.comments.count
}
end
}
end
end
Step 2: Use your custom serializer in the controller
# app/controllers/api/users_controller.rb
class Api::UsersController < ApplicationController
def show
user = User.includes(posts: :comments).find(params[:id])
render json: UserSerializer.new(user).as_json
end
end
✅ This will return structured, custom JSON exactly how you define it — no extra overhead.
Step 3: Add another PORO serializer if needed
You can break down nested objects into their own serializers for cleanliness:
# app/serializers/post_serializer.rb
class PostSerializer
def initialize(post)
@post = post
end
def as_json(*)
{
id: @post.id,
title: @post.title,
comment_count: @post.comments.count
}
end
end
Then call it inside your main serializer:
# inside UserSerializer
posts: @user.posts.map { |post| PostSerializer.new(post).as_json }
Step 4: Optional – add helper modules if needed
Use Ruby modules for reusable logic, like formatting or calculations:
module FormatHelper
def format_date(datetime)
datetime.strftime("%b %d, %Y")
end
end
# Inside serializer
include FormatHelper
Step 5: Optional – make your serializers more flexible
Pass options (like current_user or view mode):
class UserSerializer
def initialize(user, current_user: nil)
@user = user
@current_user = current_user
end
def as_json(*)
base = {
id: @user.id,
name: @user.name
}
base[:email] = @user.email if @current_user&.admin?
base
end
end
Final Output: When visiting /api/users/1
{
"id": 1,
"name": "Ali",
"email": "ali@example.com",
"post_count": 3,
"posts": [
{ "id": 101, "title": "Intro to APIs", "comments_count": 2 },
{ "id": 102, "title": "Rails Optimization", "comments_count": 4 }
]
}
💡 Examples
Example 1: Basic User Serializer
Define a custom serializer that returns only selected user data.
# app/serializers/user_serializer.rb
class UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
name: @user.name,
email: @user.email
}
end
end
✅ In controller:
render json: UserSerializer.new(@user).as_json
Example 2: Including nested posts with comment count
class UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
name: @user.name,
posts: @user.posts.map do |post|
{
id: post.id,
title: post.title,
comments_count: post.comments.size
}
end
}
end
end
✅ Output:
{
"id": 1,
"name": "Ali",
"posts": [
{
"id": 10,
"title": "Custom JSON in Rails",
"comments_count": 5
}
]
}
Example 3: Using a nested PORO serializer
Create a separate serializer for posts and nest it inside the user serializer:
# app/serializers/post_serializer.rb
class PostSerializer
def initialize(post)
@post = post
end
def as_json(*)
{
id: @post.id,
title: @post.title,
summary: @post.content.truncate(50)
}
end
end
# app/serializers/user_serializer.rb
class UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
name: @user.name,
posts: @user.posts.map { |post| PostSerializer.new(post).as_json }
}
end
end
✅ Keeps things clean, modular, and testable.
Example 4: Conditional fields based on role or context
class UserSerializer
def initialize(user, current_user: nil)
@user = user
@current_user = current_user
end
def as_json(*)
data = {
id: @user.id,
name: @user.name
}
data[:email] = @user.email if @current_user&.admin?
data
end
end
✅ Great for hiding or showing data based on permissions.
🔁 Alternatives
ActiveModel::Serializer
– More structured, but heavierBlueprinter
– Lightweight but requires external gemFast JSON API
– Very fast but uses JSON:API spec
❓ General Questions & Answers
Q1: What is a PORO serializer?
A: A PORO (Plain Old Ruby Object) serializer is a simple Ruby class you define yourself. It controls how your objects (like users, posts, etc.) are converted into hashes/JSON — without relying on any external libraries.
Q2: Why use a PORO instead of ActiveModel::Serializer or Jbuilder?
A: POROs give you full flexibility, zero dependencies, and are often faster. You write exactly what you want to return — and nothing more. Great for microservices, mobile APIs, or performance-critical apps.
Q3: Do I need to install a gem for PORO serializers?
A: No! This is the best part — you use plain Ruby. Just create a Ruby class and return a hash from a method like as_json
.
Q4: Can I use PORO serializers in any controller?
A: Yes. Just call it like this:
render json: CustomSerializer.new(@object).as_json
Q5: Can I use POROs for nested or associated data?
A: Yes! You can call other PORO serializers from inside one another — or manually map associations using .map
and .pluck
.
Q6: Is this approach scalable?
A: Absolutely. Many production APIs use PORO serializers — especially if they care about tight control over performance, permissions, or response size. Just organize your serializers well and reuse logic cleanly.
Q7: What’s the downside of PORO serializers?
A: You have to manually define every field, handle structure, and manage consistency across serializers. Unlike tools like AMS or Blueprinter, there’s no convention or automatic behavior — everything is up to you (which can also be a benefit).
🛠️ Deep Dive Technical Q&A
Q1: Can I use conditionals in PORO serializers (e.g., based on current user or roles)?
A: Yes. You can pass any context (like current_user
) as an argument and use it inside your as_json
method:
class UserSerializer
def initialize(user, current_user: nil)
@user = user
@current_user = current_user
end
def as_json(*)
data = { id: @user.id, name: @user.name }
data[:email] = @user.email if @current_user&.admin?
data
end
end
Q2: How do I serialize associations?
A: Use .map
or call nested PORO serializers for each record:
posts: @user.posts.map { |post| PostSerializer.new(post).as_json }
Q3: Can I cache the output of a serializer?
A: Yes. You can use Rails caching or memoization manually:
def as_json(*)
Rails.cache.fetch("user:#{@user.id}:json", expires_in: 10.minutes) do
{
id: @user.id,
name: @user.name
}
end
end
Q4: Can I customize the JSON root key?
A: Yes. Just wrap your hash manually:
{ user: { id: @user.id, name: @user.name } }
Q5: Can I reuse fields across multiple serializers?
A: Yes. You can extract common logic into a base module or serializer class:
module CommonFields
def created_and_updated_at(resource)
{
created_at: resource.created_at,
updated_at: resource.updated_at
}
end
end
Q6: How can I handle large collections efficiently?
A: Use .pluck
or select only required fields before serialization, and avoid loading full ActiveRecord objects when not needed:
Post.select(:id, :title).map { |p| { id: p.id, title: p.title } }
Q7: Can I write tests for my PORO serializers?
A: Yes — they’re plain Ruby classes! You can unit test them easily using RSpec or Minitest:
expect(UserSerializer.new(user).as_json).to include(name: "Ali")
✅ Best Practices with Examples
1. Keep serialization logic focused and lightweight
A PORO serializer should only build the data — keep it free of business logic or model queries.
# ✅ Good
{ id: @user.id, name: @user.name }
# ❌ Avoid this
{ balance: @user.transactions.map(&:amount).sum }
2. Separate responsibilities using nested serializers
Don’t pack too much logic into one class — break it into reusable serializers.
# UserSerializer
{ id: @user.id, posts: @user.posts.map { |p| PostSerializer.new(p).as_json } }
3. Use keyword arguments for flexibility (like current_user or context)
def initialize(user, current_user: nil, view: :public)
@user = user
@current_user = current_user
@view = view
end
✅ This keeps your serializer reusable in multiple places.
4. Hide sensitive or irrelevant fields
Never expose fields like password_digest
, auth_token
, or internal IDs.
# DO NOT return @user.attributes as JSON
# Only expose what's meant for the client
5. Use memoization if fields require expensive calculations
def as_json(*)
{
id: @user.id,
total_spent: total_spent
}
end
def total_spent
@total_spent ||= @user.orders.sum(:amount)
end
6. Make it testable — and test it!
Use RSpec or Minitest to write unit tests. Since it’s a plain Ruby object, it’s easy to test in isolation.
expect(UserSerializer.new(user).as_json).to eq({ id: 1, name: "Ali" })
7. Use helper modules for shared formatting logic
Great for formatting dates, currency, names, etc.
# FormatHelper module
def formatted_date(date)
date.strftime("%b %d, %Y")
end
8. Group your PORO serializers under app/serializers
Even though they’re plain Ruby classes, use a consistent namespace for organization.
🌍 Real-world Scenario
You’re building a custom Rails API for a mobile app. The app loads a user profile along with their 5 latest blog posts. It needs:
- ⚡ Fast API response
- 🎯 Only selected fields (not all associations)
- 📦 A predictable structure — no extra keys, root nodes, or unwanted metadata
Instead of adding ActiveModel::Serializer
or Blueprinter
, you decide to use a PORO serializer to keep things fast and minimal.
💻 Your Setup
UserSerializer:
class UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
name: @user.name,
avatar_url: @user.avatar_url,
recent_posts: @user.posts.limit(5).map do |post|
PostSerializer.new(post).as_json
end
}
end
end
PostSerializer:
class PostSerializer
def initialize(post)
@post = post
end
def as_json(*)
{
id: @post.id,
title: @post.title,
summary: @post.content.truncate(100),
published_on: @post.published_at.strftime("%b %d, %Y")
}
end
end
In the controller:
render json: UserSerializer.new(@user).as_json
✅ API Output:
{
"id": 1,
"name": "Ali",
"avatar_url": "https://cdn.example.com/avatar.jpg",
"recent_posts": [
{
"id": 101,
"title": "Intro to APIs",
"summary": "APIs are used to communicate...",
"published_on": "Apr 15, 2025"
},
...
]
}
✅ Your API response is now:
- ⚡ Fast (no gem overhead)
- 🔐 Secure (only includes required fields)
- 🧼 Clean and frontend-friendly (no extra root keys)
This is perfect for mobile apps, public APIs, or admin dashboards where performance and customization are key.
URI-based API Versioning (/api/v1/, /api/v2/)
🧠 Detailed Explanation
API versioning is the practice of managing changes in your API without breaking existing clients (mobile apps, frontend apps, integrations).
URI-based versioning includes the version number directly in the API path. This is one of the most common and widely accepted methods.
Example:
/api/v1/users → First version of your API
/api/v2/users → Improved or changed version
Why is this important?
- ✅ It allows multiple clients to use different versions at the same time
- ✅ You can ship new features without breaking old mobile apps
- ✅ You can deprecate endpoints gradually
- ✅ It’s clear and human-readable (vs. header-based versioning)
How it works in Rails:
- You define routes using
namespace :v1
and:v2
- You place controllers inside versioned folders like
api/v1/
andapi/v2/
- Each version can have a different controller or logic, and you can reuse models/services between them
✅ This is perfect for production-grade APIs that may serve web, mobile, and third-party clients.
Common Use Cases:
- 📱 Mobile app uses
/api/v1
while your new web dashboard uses/api/v2
- 💼 You deprecate older fields in v2 without affecting v1 clients
- 🔒 You tighten security or validation in v2 without breaking old integrations
In summary, URI-based API versioning is clean, intuitive, and easy to implement in Rails using namespacing.
📦 Step-by-Step Implementation
We’ll version the API using namespaces to support both /api/v1/
and /api/v2/
endpoints.
Step 1: Define versioned routes
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users
end
namespace :v2 do
resources :users
end
end
end
Step 2: Create namespaced controllers
For v1:
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
def index
users = User.all
render json: { version: "v1", users: users }
end
end
end
end
For v2 (customized response):
# app/controllers/api/v2/users_controller.rb
module Api
module V2
class UsersController < ApplicationController
def index
users = User.select(:id, :name).limit(10)
render json: { version: "v2", data: users }
end
end
end
end
Step 3: Organize controllers in folders
- Create
app/controllers/api/v1/
- Create
app/controllers/api/v2/
Each version should have its own isolated controller files.
Step 4: Optionally use namespaced serializers
You can also version your JSON output logic:
# app/serializers/api/v1/user_serializer.rb
class Api::V1::UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
name: @user.name,
email: @user.email
}
end
end
# app/serializers/api/v2/user_serializer.rb
class Api::V2::UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
display_name: "#{@user.name.upcase}"
}
end
end
Step 5: Test versioned routes
Start your Rails server and test:
GET /api/v1/users
GET /api/v2/users
✅ You’ll see different responses based on version.
📌 Pro Tip:
- Group shared logic into service objects if needed
- Version only when making breaking changes
- Document each version clearly in your API docs
✅ You now have a scalable, future-proof versioned Rails API using clean URI structure!
💡 Examples
Example 1: Route definitions
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users
end
namespace :v2 do
resources :users
end
end
end
✅ This creates:
/api/v1/users
/api/v2/users
Example 2: v1 Controller — full user details
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
def index
render json: User.all.as_json(only: [:id, :name, :email])
end
end
end
end
Example 3: v2 Controller — simplified fields + extra metadata
# app/controllers/api/v2/users_controller.rb
module Api
module V2
class UsersController < ApplicationController
def index
users = User.select(:id, :name).limit(10)
render json: {
version: "v2",
data: users,
meta: {
count: users.size,
timestamp: Time.current
}
}
end
end
end
end
Example 4: Versioned Serializers (Optional)
# app/serializers/api/v1/user_serializer.rb
class Api::V1::UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
name: @user.name,
email: @user.email
}
end
end
# app/serializers/api/v2/user_serializer.rb
class Api::V2::UserSerializer
def initialize(user)
@user = user
end
def as_json(*)
{
id: @user.id,
display_name: @user.name.upcase
}
end
end
Example 5: Switching logic in the controller based on version
def index
if request.path.include?("/v1")
render json: Api::V1::UserSerializer.new(user).as_json
else
render json: Api::V2::UserSerializer.new(user).as_json
end
end
✅ This allows total flexibility and clean backward compatibility across versions.
🔁 Alternative Methods
- Header-based versioning:
Accept: application/vnd.myapp.v1+json
- Query param versioning:
/api/users?version=1
❓ General Questions & Answers
Q1: What is URI-based versioning?
A: URI-based versioning means putting the version number directly in your API route, such as /api/v1/users
. It’s simple, visible, and supported by all clients.
Q2: Why should I use API versioning at all?
A: Versioning protects old clients from breaking when you make changes to your API (like removing fields or changing behavior). It allows you to improve and iterate on your API safely.
Q3: How is URI versioning better than header-based versioning?
A:
– URI versioning is visible in logs and browser.
– It’s cache-friendly.
– No need for special client support (like custom headers).
– Easier to test and document.
Q4: Do I need to duplicate everything for each version?
A: No. You only duplicate what changes. Shared logic (models, services, serializers) can be reused across versions.
Q5: Can I rename or delete old versions?
A: Yes, but only after communicating with your API consumers and giving them time to migrate. You can phase out v1 by first marking it as deprecated.
Q6: What if I only have one API version now?
A: Still include a version in your route (e.g., /api/v1
) to future-proof your app. That way, you don’t have to restructure later.
Q7: Is versioning only for public APIs?
A: No! Even private APIs (used by your frontend, mobile apps, etc.) benefit from versioning. It reduces the risk of breaking something when teams make changes independently.
🛠️ Deep Dive Technical Q&A
Q1: How do I avoid code duplication across API versions?
A: Extract shared logic into service classes, serializers, or parent controllers:
# app/services/user_fetcher.rb
class UserFetcher
def self.latest(limit = 10)
User.order(created_at: :desc).limit(limit)
end
end
Then call it in both v1 and v2 controllers.
Q2: How do I test multiple versions of the same endpoint?
A: Use separate request specs for each version:
# spec/requests/api/v1/users_spec.rb
describe "GET /api/v1/users" do
it "returns v1 structure" do
get "/api/v1/users"
expect(response.body).to include("version: v1")
end
end
Q3: Can I override a controller method only in v2?
A: Yes. Just redefine it in the Api::V2
version. Rails will route to the correct namespace.
Q4: Can I customize serializers per version?
A: Absolutely. Use namespaced serializers:
# app/serializers/api/v1/user_serializer.rb
# app/serializers/api/v2/user_serializer.rb
Q5: How do I support deprecated versions?
A: Add a warning in the response header or JSON body:
response.set_header("X-API-Deprecated", "This version will be removed on 2025-06-01")
Q6: Can I share parent controllers between versions?
A: Yes. Create a shared base controller:
# app/controllers/api/base_controller.rb
module Api
class BaseController < ApplicationController
def current_api_user
# shared logic
end
end
end
Then inherit in v1/v2 controllers:
class Api::V1::UsersController < Api::BaseController
class Api::V2::UsersController < Api::BaseController
Q7: Is it possible to redirect old versions to new ones?
A: Yes, though not always recommended. You can do this in routes or controllers for temporary migration support:
# routes.rb
get '/api/v1/users', to: redirect('/api/v2/users')
✅ Best Practices with Examples
1. Start with a versioned structure from day one
Even if you only have one version, add /v1/
so your future self (and your clients) won’t have to refactor everything.
/api/v1/users ✅ (future-proof)
/api/users ❌ (harder to version later)
2. Use namespaced controllers for each version
# app/controllers/api/v1/users_controller.rb
# app/controllers/api/v2/users_controller.rb
Don’t rely on conditionals inside one controller to simulate versions — keep them separate and clean.
3. Extract shared logic to service objects or parent classes
This avoids repeating the same queries or processing in every version.
# app/services/user_fetcher.rb
UserFetcher.fetch_latest
4. Version only when breaking changes are needed
Don’t make a new version for every small tweak. Use versions only for changes that will break older clients.
5. Add deprecation headers or warnings in older versions
response.set_header("X-API-Deprecated", "v1 will be removed on 2025-12-31")
This gives your clients time to upgrade.
6. Document each version separately
Use Swagger, Postman, or a markdown file for each version. Don’t assume clients will know the difference between v1 and v2.
7. Group all API controllers under app/controllers/api/
app/controllers/api/v1/
app/controllers/api/v2/
app/controllers/api/base_controller.rb
✅ This keeps your project well-structured and easy to navigate.
8. Keep responses consistent per version
If v1/users
returns a root key data
, all v1 endpoints should follow that convention — same for v2.
9. Write separate request specs for each version
spec/requests/api/v1/users_spec.rb
spec/requests/api/v2/users_spec.rb
This ensures version-specific behaviors don’t get mixed or broken.
🌍 Real-world Scenario
You’re building a Rails API for a mobile application. The app’s current version uses /api/v1/
for all data — like users, posts, and comments.
Later, you receive new requirements:
- 🎯 Change
users
to return a display name instead of name + email - 🚀 Add pagination support to posts
- 🔒 Remove sensitive fields like
auth_token
from responses
But… you have 10,000+ users using the old mobile app version — you can’t break them.
✅ Solution: Introduce /api/v2/
namespace
1. Update routes:
namespace :api do
namespace :v1 do
resources :users
resources :posts
end
namespace :v2 do
resources :users
resources :posts
end
end
2. Create v2 controller versions:
# app/controllers/api/v2/users_controller.rb
module Api
module V2
class UsersController < ApplicationController
def index
render json: User.select(:id, :username).map { |u| { id: u.id, display_name: u.username.upcase } }
end
end
end
end
3. Leave /api/v1/users
untouched
4. Communicate the v2 upgrade to your frontend/mobile teams
🚀 Results:
- 📱 Old mobile clients keep working using
/api/v1
- 🌐 New web dashboard uses the improved
/api/v2
routes - ✅ You can add new features, remove legacy code gradually, and migrate traffic safely
💡 Many companies like GitHub, Stripe, Shopify, and Twitter use versioning to maintain backward compatibility while evolving their APIs — and URI-based versioning is the most readable and dev-friendly way to do it.
Header-based API Versioning
🧠 Detailed Explanation
Header-based API versioning means the API version is passed through a request header instead of the URL.
Most commonly, clients send a custom Accept
header using a vendor-specific media type format:
Accept: application/vnd.myapp.v1+json
Your Rails application reads this header, extracts the version number, and responds accordingly.
💡 Why use header-based versioning?
- ✅ Keeps URLs clean (
/api/users
instead of/api/v1/users
) - ✅ Aligns with REST and content negotiation standards
- ✅ Good for APIs needing flexibility across multiple formats (like XML, JSON, etc.)
📦 How it works in Rails
- Client sends a request with a header like:
Accept: application/vnd.myapp.v2+json
- The server reads and parses the header
- The controller logic chooses the right version (or falls back to default)
🚫 Drawbacks
- ❌ Harder to test manually in a browser (requires tools like Postman or curl)
- ❌ Not cache-friendly unless you configure reverse proxies correctly
- ❌ Slightly more complex logic in controller setup
In summary, header-based versioning is a powerful option when you want to keep routes clean and follow strict REST practices — especially in APIs consumed by mobile apps or 3rd parties.
📦 Step-by-Step Implementation
This approach uses a custom media type header like:
Accept: application/vnd.myapp.v1+json
Step 1: Setup a single versioned controller
# app/controllers/api/users_controller.rb
module Api
class UsersController < ApplicationController
before_action :detect_version
def index
case @api_version
when "1"
render json: { version: "v1", users: User.all }
when "2"
render json: { version: "v2", users: User.limit(5).select(:id, :name) }
else
render json: { error: "Invalid API version" }, status: 406
end
end
private
def detect_version
accept = request.headers["Accept"].to_s
match = accept.match(/vnd\.myapp\.v(\d+)\+json/)
@api_version = match ? match[1] : "1"
end
end
end
Step 2: Call the endpoint using cURL or Postman
curl -H "Accept: application/vnd.myapp.v1+json" https://yourdomain.com/api/users
curl -H "Accept: application/vnd.myapp.v2+json" https://yourdomain.com/api/users
✅ This calls the same endpoint, but logic changes based on the header.
Step 3: Add default version fallback
@api_version = match ? match[1] : "1"
This ensures clients that don’t send the header still get a valid response.
Step 4: (Optional) Extract version detection to a concern
# app/controllers/concerns/version_detectable.rb
module VersionDetectable
def api_version
@api_version ||= begin
accept = request.headers["Accept"].to_s
accept[/vnd\.myapp\.v(\d+)\+json/, 1] || "1"
end
end
end
Then use it in any controller with:
include VersionDetectable
before_action { @api_version = api_version }
Step 5: Structure your serializers or services by version (optional)
# app/serializers/api/v1/user_serializer.rb
# app/serializers/api/v2/user_serializer.rb
render json: Api::V2::UserSerializer.new(@user).as_json
✅ Done! You now support versioned APIs via headers — with no URL changes, no routing duplication, and full flexibility.
💡 Examples
Example 1: Sending a versioned request with curl
curl -H "Accept: application/vnd.myapp.v1+json" https://yourapi.com/api/users
curl -H "Accept: application/vnd.myapp.v2+json" https://yourapi.com/api/users
✅ These two requests will hit the same URL but trigger different logic based on the header.
Example 2: Basic version detection in controller
class Api::UsersController < ApplicationController
before_action :set_version
def index
if @version == "2"
render json: User.limit(5).select(:id, :name)
else
render json: User.all
end
end
private
def set_version
accept_header = request.headers["Accept"].to_s
match = accept_header.match(/vnd\.myapp\.v(\d+)\+json/)
@version = match ? match[1] : "1"
end
end
Example 3: Using versioned serializers
if @version == "2"
render json: Api::V2::UserSerializer.new(@user).as_json
else
render json: Api::V1::UserSerializer.new(@user).as_json
end
Example 4: RSpec request spec to test header-based versioning
# spec/requests/api/users_spec.rb
describe "API versioning" do
it "returns v1 by default" do
get "/api/users"
expect(response.body).to include("v1")
end
it "returns v2 response when Accept header is set" do
get "/api/users", headers: {
"Accept" => "application/vnd.myapp.v2+json"
}
expect(response.body).to include("v2")
end
end
Example 5: Setting a default version
@version = match ? match[1] : "1"
✅ This ensures older clients without headers still receive valid responses.
🔁 Alternatives
- URI versioning:
/api/v1/users
- Query versioning:
/api/users?version=1
❓ General Questions & Answers
Q1: What is header-based API versioning?
A: It’s a method of telling your API which version you want to use by setting a custom Accept
header. For example:
Accept: application/vnd.myapp.v2+json
Q2: Why use headers instead of putting version in the URL?
A: Header versioning keeps your URLs clean and follows the HTTP content negotiation pattern. It separates versioning from routing, which some consider more RESTful.
Q3: Will this work in a browser?
A: Not directly — browsers don’t easily let you set custom headers. Header-based versioning is more suited for mobile apps, HTTP clients like Postman, or API integrations.
Q4: What happens if a client doesn’t send a version header?
A: You can define a default version (usually the oldest supported) in your controller logic:
@version = match ? match[1] : "1"
Q5: Can I log the version clients are using?
A: Yes — log the Accept
header or extracted version to help with analytics or debugging.
Q6: How do I document header versioning for frontend or API clients?
A: Clearly state in your API docs what versioned headers are required, such as:
Accept: application/vnd.myapp.v1+json
You can also include a testable example (curl or Postman) in your docs.
Q7: Is this better than URI versioning?
A: Not better — just different. URI versioning is easier for users to see and test. Header versioning is more REST-pure and scalable for enterprise-style APIs. It depends on your audience and tooling.
🛠️ Deep Dive Technical Q&A
Q1: How do I extract the version from the Accept header in Rails?
A: Use a regex inside a before_action:
def set_version
header = request.headers["Accept"].to_s
match = header.match(/vnd\.myapp\.v(\d+)\+json/)
@version = match ? match[1] : "1"
end
Q2: Can I redirect traffic to a specific version if the header is missing?
A: Yes. You can fallback to a default version using:
@version = "1" unless match
Or even raise a warning if a version isn’t specified.
Q3: Can I organize controllers or serializers per version?
A: Yes! Store versioned logic like:
app/controllers/api/v1/users_controller.rb
app/serializers/api/v2/user_serializer.rb
Then load the right one dynamically based on the extracted version.
Q4: Can I define middleware for header-based version routing?
A: Yes. You can build custom Rack middleware to parse the Accept header and reassign routing logic, but this is advanced and typically overkill unless you want full decoupling.
Q5: Will this versioning work with ActiveModelSerializers or Blueprinter?
A: Absolutely. Choose your serializer based on version logic:
if @version == "1"
render json: V1::UserSerializer.new(user).as_json
else
render json: V2::UserSerializer.new(user).as_json
end
Q6: Can I test this in RSpec?
A: Yes. Pass the header in the test like this:
get "/api/users", headers: {
"Accept" => "application/vnd.myapp.v2+json"
}
Q7: Can I expose version info in the response?
A: Yes — add a header or field in the JSON response:
response.set_header("API-Version", @version)
# OR
render json: { version: @version, data: ... }
✅ Best Practices
- Use meaningful vendor-specific content types
- Set default version if header is missing
- Log the requested version for debugging
- Document clearly how clients should set headers
🌍 Real-world Scenario
An external partner integrates with your API and needs a stable interface. Instead of breaking their implementation when you update, you version by header. Their mobile app sends Accept: application/vnd.myapp.v1+json
and continues working while you roll out v2
for your web frontend.
Namespaced Controllers and Routes
🧠 Detailed Explanation
Namespacing in Rails allows you to organize your application’s controllers, routes, and other components into logical groups — typically for:
- 📦 API versioning (e.g.,
Api::V1
,Api::V2
) - 🔐 Admin dashboards (
Admin::UsersController
) - 🎯 Feature-based separation (
Storefront::OrdersController
)
This helps avoid file clutter and method conflicts as your app grows.
📁 Folder Structure
When you namespace a controller in Rails, you also create folders that match:
app/controllers/api/v1/users_controller.rb
And the class name inside should match the nesting:
module Api
module V1
class UsersController < ApplicationController
...
end
end
end
🔗 Routing
Routes follow the same logic. This defines routes inside a versioned API:
namespace :api do
namespace :v1 do
resources :users
end
end
✅ This creates RESTful endpoints like /api/v1/users
mapped directly to Api::V1::UsersController
.
🧩 Benefits
- ✅ Clear file organization
- ✅ Enables multiple versions without conflict
- ✅ Easier to test and maintain independently
- ✅ Supports future scaling like public/admin APIs
🆚 Flat Controllers vs Namespaced
app/controllers/users_controller.rb
– ✅ Simple for small appsapp/controllers/api/v1/users_controller.rb
– ✅ Better for growing APIs or admin separation
🔍 In short, namespaced controllers make your codebase easier to maintain, especially when adding API versions, user roles, or feature modules.
📦 Step-by-Step Implementation
We’ll create namespaced API routes and organize the controllers accordingly.
Step 1: Define namespaced routes
Edit your config/routes.rb
like this:
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users
end
end
end
✅ This creates /api/v1/users
and maps it to the right controller automatically.
Step 2: Create the folder structure
Create the controller file at:
app/controllers/api/v1/users_controller.rb
And define the controller inside it:
module Api
module V1
class UsersController < ApplicationController
def index
users = User.all
render json: users
end
end
end
end
Step 3: (Optional) Add a base controller for shared logic
If you have version-specific filters or helpers:
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ApplicationController
before_action :authenticate_api_key!
end
end
end
Then inherit from it:
class UsersController < Api::V1::BaseController
...
end
Step 4: Verify routes
Run rails routes
and confirm:
api_v1_users GET /api/v1/users(.:format) api/v1/users#index
Step 5: Test it in the browser or curl
http://localhost:3000/api/v1/users
curl http://localhost:3000/api/v1/users
💡 Pro Tips
- Use namespacing to support
v1
,v2
, or admin APIs - Group controllers and serializers the same way:
app/serializers/api/v1
- Always write request specs per namespace to avoid routing conflicts
✅ Now your API or admin controllers are properly isolated, versioned, and scalable!
💡 Examples
Example 1: Define a namespaced route in routes.rb
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users
end
end
end
✅ This generates routes like:
GET /api/v1/users → Api::V1::UsersController#index
POST /api/v1/users → Api::V1::UsersController#create
Example 2: Create a namespaced controller
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
def index
render json: User.all
end
end
end
end
Example 3: Add another module namespace (e.g., Admin)
# config/routes.rb
namespace :admin do
resources :dashboard, only: [:index]
end
# app/controllers/admin/dashboard_controller.rb
module Admin
class DashboardController < ApplicationController
def index
render plain: "Welcome, admin!"
end
end
end
🧭 Route: GET /admin/dashboard
Example 4: Use a versioned base controller
# app/controllers/api/v1/base_controller.rb
module Api
module V1
class BaseController < ApplicationController
before_action :authenticate_api!
def authenticate_api!
# logic here
end
end
end
end
# UsersController inherits from versioned base
class Api::V1::UsersController < Api::V1::BaseController
end
Example 5: Test your route generation
$ rails routes | grep api
api_v1_users GET /api/v1/users(.:format) api/v1/users#index
🔁 Alternatives
- Flat controller structure:
users_controller.rb
- Route constraints with scopes instead of namespaces
❓ General Questions & Answers
Q1: What does namespacing mean in Rails?
A: Namespacing means organizing code into logical folders/modules (like Api::V1
, Admin
) so your routes and controllers stay clean and manageable — especially in larger apps.
Q2: Why should I use namespaces instead of flat controllers?
A: Flat controllers (like users_controller.rb
) work well for small apps, but namespaced controllers help:
- ✅ Separate API versions
- ✅ Avoid naming conflicts (e.g.,
Admin::UsersController
vsApi::UsersController
) - ✅ Maintainable and scalable architecture
Q3: Does the folder name matter?
A: Yes! The folder structure must match the namespace. Example:
Api::V1::UsersController → app/controllers/api/v1/users_controller.rb
Q4: Can I namespace just the routes but use flat controllers?
A: Technically yes, but it’s not recommended. If you namespace your routes, your controller class names and file paths should follow the same convention for clarity and autoloading.
Q5: Do namespaced controllers inherit from ApplicationController
?
A: Yes — unless you define a custom base controller (like Api::V1::BaseController
) for reusable logic within a namespace.
Q6: Is namespacing only for APIs?
A: Not at all! It’s also common for organizing admin panels (Admin::OrdersController
), separate dashboards, or multi-role systems.
Q7: Does this affect how URLs are written?
A: Yes. The URL path will include the namespace. For example:
GET /api/v1/users → Api::V1::UsersController#index
GET /admin/orders → Admin::OrdersController#index
🛠️ Deep Dive Technical Q&A
Q1: How do I structure files for namespaced controllers?
A: Your controller class and file path must match:
class Api::V1::UsersController < ApplicationController
# should be saved at:
app/controllers/api/v1/users_controller.rb
Q2: Can I have shared logic across different versions or modules?
A: Yes. Create a custom base controller like:
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
before_action :authenticate_user!
end
Then have versioned controllers inherit from it.
Q3: How do I generate a namespaced controller via Rails CLI?
A: Use double colons or slashes:
rails g controller api/v1/users
# OR
rails g controller Api::V1::Users
Q4: Can I use different serializers per namespace?
A: Yes. You can organize serializers in:
app/serializers/api/v1/user_serializer.rb
app/serializers/api/v2/user_serializer.rb
Then load them dynamically based on version or route.
Q5: Can I apply middleware or filters to just one namespace?
A: Yes. Place shared logic (like rate limits or headers) in a namespaced base controller. Example:
class Api::V2::BaseController < ApplicationController
before_action :set_cors_headers
end
Q6: Can I write RSpec request specs per namespace?
A: Yes. Structure your specs like:
spec/requests/api/v1/users_spec.rb
spec/requests/admin/orders_spec.rb
And hit endpoints like /api/v1/users
.
Q7: Will Rails autoload the namespaced controllers?
A: Yes, as long as your folder structure and module names match. Always follow Rails naming conventions for smooth autoloading and reloading in dev mode.
✅ Best Practices with Examples
1. Always match module names with folder structure
Rails autoloading depends on this. For example:
module Api
module V1
class UsersController < ApplicationController
end
end
end
# ➜ Save it as:
app/controllers/api/v1/users_controller.rb
2. Use namespacing for API versioning or access roles
Api::V1::UsersController
→ for versioned APIsAdmin::UsersController
→ for staff-only actionsStorefront::OrdersController
→ for public shopping views
3. Use base controllers per namespace
Helps DRY up logic like authentication, headers, and logging.
class Api::V1::BaseController < ApplicationController
before_action :authenticate_user!
end
4. Avoid mixing logic across namespaces
Don’t reuse a controller between Admin
and Api
. Instead, extract common logic into service objects.
5. Test each namespace independently
Structure request specs accordingly:
spec/requests/api/v1/users_spec.rb
spec/requests/admin/dashboard_spec.rb
6. Group serializers and presenters by namespace too
app/serializers/api/v1/user_serializer.rb
app/serializers/admin/user_serializer.rb
✅ Keeps versioned responses clean and controlled.
7. Use consistent naming in routes
namespace :api do
namespace :v2 do
resources :users, path: "members"
end
end
This generates /api/v2/members
, but still maps to UsersController
.
8. Use `rails g controller` with path to avoid typos
rails g controller api/v1/users
# creates correct module + file path
🌍 Real-world Scenario
Imagine you’re building a platform like Shopify or Stripe. You need to serve:
- 📱 A public-facing API for mobile apps and frontend (React/Vue)
- 🧑💼 An internal admin dashboard
- 🔀 A versioned API for external partners
✅ Namespaced Structure:
Api::V1::UsersController
→ Public APIApi::V2::UsersController
→ Improved logic for new appsAdmin::UsersController
→ Admin dashboard for internal users
This lets you build completely separate logic for:
- ✅ Response format (e.g., JSON API vs admin HTML)
- ✅ Access control (e.g., token-based vs session auth)
- ✅ Feature rollout (e.g., beta version for new APIs)
📁 Folder Structure in Your App
app/
├── controllers/
│ ├── api/
│ │ └── v1/
│ │ └── users_controller.rb
│ │ └── v2/
│ │ └── users_controller.rb
│ └── admin/
│ └── users_controller.rb
✅ You can even separate serializers, views, and specs under matching folders.
🚀 Benefits in Production:
- 🛡️ Reduced risk of breaking changes — v1 and v2 live side by side
- 🔐 Admin logic and UI stay secure and separate from public API
- 📦 Easier to test and maintain modules independently
- 👨👩👧👦 Multiple teams can work on different versions safely
✅ This is exactly how large-scale APIs and admin systems are built — clean, testable, and scalable using namespaced architecture in Rails.
Token-based Authentication (has_secure_token)
🧠 Detailed Explanation
Token-based authentication is a stateless way to authenticate users in APIs and mobile applications. Instead of using cookies or sessions, it uses a secure token (usually stored in the client) to validate each request.
In Rails, has_secure_token
is a built-in method that makes it easy to implement this system. It generates a secure, random token and stores it in a database column like auth_token
.
🔐 How it works:
- When a new user is created, Rails automatically generates a token and stores it in
auth_token
. - The client app (like React or a mobile app) stores this token securely after login.
- Every time the client makes an API request, it sends this token in the request header (
Authorization
). - The Rails app finds the user by this token and gives access if valid.
✅ This is stateless — meaning Rails doesn’t need to track sessions or cookies. Each request is independently authenticated by the token.
🧠 About has_secure_token
:
- Creates a 24-character random string using
SecureRandom.base58
- Automatically sets the token on record creation
- Lets you regenerate the token using
record.regenerate_token_column
- Works with any column name — just pass it as an argument (e.g.,
has_secure_token :api_key
)
✅ Benefits:
- 🔒 Cryptographically secure
- 🌐 Stateless and scalable — no server-side session tracking
- 🔁 Easy to reset token on logout or compromise
- 💡 Great for APIs, SPAs, mobile apps
In short, has_secure_token
is a simple and secure way to implement token-based auth in Rails — perfect for modern, API-driven applications.
📦 Step-by-Step Implementation
Let’s build a simple API authentication system using has_secure_token
.
Step 1: Add a token column to your model
rails generate migration AddAuthTokenToUsers auth_token:string:uniq
rails db:migrate
✅ This adds a unique auth_token
field to your users
table.
Step 2: Add has_secure_token
to your model
# app/models/user.rb
class User < ApplicationRecord
has_secure_token :auth_token
end
Rails will now automatically generate a secure token for new users and let you regenerate it later with:
user.regenerate_auth_token
Step 3: Authenticate incoming requests using token
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authenticate_user!
def authenticate_user!
token = request.headers["Authorization"]
@current_user = User.find_by(auth_token: token)
unless @current_user
render json: { error: "Unauthorized" }, status: :unauthorized
end
end
end
✅ Now any controller inheriting from ApplicationController
is protected by token authentication.
Step 4: Return token to user on login
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: [:create]
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
render json: { token: user.auth_token }
else
render json: { error: "Invalid credentials" }, status: :unauthorized
end
end
end
Step 5: Use the token in client requests
Clients should include the token in the Authorization
header:
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
✅ You now have a simple, stateless authentication system ready to protect any API endpoint.
💡 Optional Add-ons:
- 🔄 Add
logout
by regenerating the token - ⏳ Add an
auth_token_expires_at
column for time-limited tokens - 🔐 Use
before_action
conditionally withskip_before_action
💡 Examples
Example 1: Migration to add auth_token
field
# db/migrate/xxxxx_add_auth_token_to_users.rb
class AddAuthTokenToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :auth_token, :string
add_index :users, :auth_token, unique: true
end
end
✅ This creates a secure field and ensures uniqueness in the database.
Example 2: Enable token generation in model
# app/models/user.rb
class User < ApplicationRecord
has_secure_token :auth_token
end
✅ Now a secure token is generated when a user is created.
Example 3: Create user with token
u = User.create(name: "Ali", email: "ali@example.com", password: "123456")
puts u.auth_token
# Output: "j28kXcR6f93ZSkvJ5aLgDY1H"
✅ Token is generated automatically after save.
Example 4: Regenerate token manually (logout/reset)
user.regenerate_auth_token
✅ This invalidates the old token and generates a new one.
Example 5: Authenticate token in controller
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authenticate_user!
def authenticate_user!
token = request.headers["Authorization"]
@current_user = User.find_by(auth_token: token)
unless @current_user
render json: { error: "Unauthorized" }, status: 401
end
end
end
Example 6: API login response
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
skip_before_action :authenticate_user!, only: [:create]
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
render json: { token: user.auth_token }
else
render json: { error: "Invalid credentials" }, status: 401
end
end
end
✅ After login, frontend stores and sends this token in every request.
🔁 Alternatives
has_secure_password
(for login/password)- JWT (JSON Web Tokens) for stateless auth
- Devise Token Auth / Doorkeeper (OAuth2)
❓ General Questions & Answers
Q1: What is token-based authentication?
A: It’s a way of authenticating users using a unique token instead of sessions or cookies. The client sends the token with every request, and the server verifies it to identify the user.
Q2: What does has_secure_token
do in Rails?
A: It automatically generates a random, secure token for a model attribute (like auth_token
) and provides a method to regenerate it when needed.
Q3: When is this useful?
A: It’s especially useful for:
- 🔐 API authentication (e.g., mobile or frontend apps)
- 📧 Passwordless login systems (magic links)
- 🚪 Logout flows (by regenerating the token)
Q4: Where should the client send the token?
A: In the request header:
Authorization: your_token_here
This keeps it secure and separate from other params.
Q5: Can I use it instead of Devise or JWT?
A: Yes, for simple token-based systems. But if you need features like token expiry, scopes, or stateless claims, consider JWT or OAuth-based solutions like Doorkeeper.
Q6: Will the token stay the same forever?
A: Yes, unless you manually call regenerate_auth_token
. It’s up to you to handle logout, revocation, or rotation.
Q7: How secure is the token?
A: Very secure. It’s generated using SecureRandom.base58
, which produces a cryptographically secure, URL-safe 24-character token.
🛠️ Deep Dive Technical Q&A
Q1: How does has_secure_token
generate a token?
A: It uses SecureRandom.base58(24)
internally, producing a 24-character cryptographically secure string that’s safe for URLs and headers.
Q2: Can I use a custom column name for the token?
A: Yes! Just pass it as an argument:
has_secure_token :api_key
Then call regenerate_api_key
to reset it.
Q3: Can I have multiple tokens on one model?
A: Yes. You can use has_secure_token
multiple times with different column names (e.g., :auth_token
, :reset_token
), and they’ll each get separate methods.
Q4: When is the token generated?
A: When the model is created (before_create
), Rails sets the token if the field is nil
.
Q5: How do I force a new token?
A: Call the auto-generated method:
user.regenerate_auth_token
user.regenerate_reset_token
Q6: Should I validate uniqueness?
A: Yes. Always add a unique index in the database:
add_index :users, :auth_token, unique: true
This ensures no two users can ever share a token, even in rare edge cases.
Q7: Can I expire or limit token lifespan?
A: Not by default. You can add a column like token_expires_at
and check it manually in your authenticate_user!
method:
if user.token_expires_at < Time.current
render json: { error: "Token expired" }, status: :unauthorized
end
Q8: Can I store tokens securely (like hashed)?
A: has_secure_token
does not hash tokens like has_secure_password
. If you need hashed tokens, consider using digest
storage + comparison manually (e.g., using SHA256).
✅ Best Practices with Examples
1. Always add a unique index to your token column
This ensures Rails won’t allow duplicate tokens in edge cases.
add_index :users, :auth_token, unique: true
2. Store tokens in headers, not URLs
Headers are more secure and don’t leak tokens in logs or browser history.
Authorization: your_token_here
3. Use has_secure_token
for simplicity in small to mid-sized APIs
It’s a great lightweight alternative to OAuth or JWT when you don’t need complex claims.
4. Regenerate tokens on logout or security breach
Use:
current_user.regenerate_auth_token
This immediately invalidates the old token.
5. Use HTTPS for all requests
Even secure tokens can be intercepted on unencrypted connections. Always use HTTPS.
6. Avoid exposing token values in logs or error messages
Sanitize logs by filtering auth_token
or Authorization
headers in config/initializers/filter_parameter_logging.rb
.
7. Support token expiration for advanced flows
Add a token_expires_at
column and enforce it in your auth logic:
if user.token_expires_at < Time.current
render json: { error: "Token expired" }, status: :unauthorized
end
8. Write specs to test token validity and regeneration
Verify tokens are unique, regeneratable, and required for secured endpoints.
🌍 Real-world Scenario
You’re building a mobile-first social media application. Users sign up and log in from their mobile device. Once logged in, the app receives a secure token and stores it locally in the device (e.g., in secure storage or local storage).
Instead of using sessions or cookies (which are browser-based), your API is stateless. That means every API request from the app must send an Authorization
header containing the user’s auth_token
.
✅ Setup:
- Backend: Rails API with
has_secure_token
on theUser
model - Mobile App: Stores
auth_token
after login - Header example:
Authorization: tG98vK3EyxJX71uBqWZw5Hcs
💡 What happens on logout?
When the user logs out, the mobile app makes a request to the logout endpoint. The server calls:
current_user.regenerate_auth_token
This invalidates the old token — so even if someone copied it, they won’t be able to use it anymore.
🔐 Why is this effective?
- 🔒 Secure random tokens — not guessable
- 🧾 No session tracking — server stays stateless
- 💥 Easy to revoke access (just regenerate the token)
- 📲 Mobile-friendly — ideal for apps using React Native, Flutter, etc.
In summary, has_secure_token
provides a clean, simple, and secure authentication strategy for real-world applications where session storage isn’t ideal — especially APIs and mobile-first products.
Devise + Devise-JWT
🧠 Detailed Explanation
Devise is a full-featured authentication library for Rails that simplifies common auth needs: login, registration, password recovery, session handling, and more.
By default, Devise uses cookie-based session storage, which is great for traditional web apps. However, for APIs or mobile apps, a **stateless authentication system** is preferred — which is where Devise-JWT comes in.
🔐 What is Devise-JWT?
devise-jwt
is an extension that allows Devise to generate and validate JSON Web Tokens (JWT).
JWTs are secure, self-contained tokens that:
- Are sent in HTTP headers instead of cookies
- Contain encrypted payloads (user data, expiration)
- Do not require server-side sessions
🧩 How It Works
- ✅ A user logs in using the
/login
endpoint - ✅ Devise issues a signed JWT
- ✅ The token is returned in the response header (or body)
- ✅ On every API request, the client sends the token in the
Authorization
header - ✅ Devise-JWT validates and decodes the token to identify the user
- ✅ You can optionally revoke the token on logout (using denylist or whitelist)
📦 Revocation Strategies
- Denylist: Save used tokens in a DB table and block reused tokens
- Allowlist: Store only the latest token per user
- None: Stateless JWT with expiration only — faster but less secure
🎯 When to Use
- 📱 Mobile apps (React Native, Flutter, iOS, Android)
- 💻 Frontend SPAs (React, Vue, Angular)
- 🌐 Stateless APIs with high concurrency
In short, Devise + Devise-JWT gives you the best of both worlds: Devise’s robust auth logic + JWT’s stateless, token-based approach — ideal for secure API-first architectures.
📦 Step-by-Step Implementation
We’ll configure token-based authentication using Devise + Devise-JWT with a JwtDenylist
revocation strategy.
Step 1: Add the required gems
# Gemfile
gem 'devise'
gem 'devise-jwt'
Then run:
bundle install
rails generate devise:install
rails generate devise User
rails db:migrate
Step 2: Create a JWT denylist table
This will store invalidated tokens (for logout and rotation):
rails generate migration CreateJwtDenylist \
jti:string:index exp:datetime
Edit migration if needed, then run:
rails db:migrate
Step 3: Create the JwtDenylist
model
# app/models/jwt_denylist.rb
class JwtDenylist < ApplicationRecord
include Devise::JWT::RevocationStrategies::Denylist
self.table_name = 'jwt_denylist'
end
Step 4: Update the User
model
Include JWT support and link the denylist strategy:
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end
Step 5: Configure Devise to use JWT
# config/initializers/devise.rb
config.jwt do |jwt|
jwt.secret = Rails.application.credentials.devise[:jwt_secret_key]
jwt.dispatch_requests = [
['POST', %r{^/login$}]
]
jwt.revocation_requests = [
['DELETE', %r{^/logout$}]
]
jwt.expiration_time = 1.day.to_i
end
🔐 Make sure to store jwt_secret_key
securely in your credentials file.
Step 6: Add custom login and logout routes
# config/routes.rb
devise_for :users, controllers: {
sessions: 'users/sessions'
}
Step 7: Create a custom sessions controller
# app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
respond_to :json
private
def respond_with(resource, _opts = {})
render json: { message: 'Logged in successfully.', token: request.env['warden-jwt_auth.token'] }, status: :ok
end
def respond_to_on_destroy
head :no_content
end
end
Step 8: Add JWT support to Devise responses
# config/initializers/devise.rb (inside Devise.setup)
config.navigational_formats = []
✅ This disables HTML redirects and makes Devise fully JSON-ready.
🚀 You now have fully working token-based authentication using Devise + JWT!
- POST
/login
→ returns JWT - DELETE
/logout
→ revokes token - Include
Authorization: Bearer <token>
in future API requests
💡 Examples
Example 1: Enable Devise + JWT on User model
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
end
✅ This activates JWT handling and links a revocation strategy.
Example 2: JWT Denylist model
# app/models/jwt_denylist.rb
class JwtDenylist < ApplicationRecord
include Devise::JWT::RevocationStrategies::Denylist
self.table_name = 'jwt_denylist'
end
✅ Rails will use this to store invalidated tokens (e.g., after logout).
Example 3: Devise JWT configuration
# config/initializers/devise.rb
config.jwt do |jwt|
jwt.secret = Rails.application.credentials.devise[:jwt_secret_key]
jwt.dispatch_requests = [['POST', %r{^/login$}]]
jwt.revocation_requests = [['DELETE', %r{^/logout$}]]
jwt.expiration_time = 1.day.to_i
end
✅ This sets up token dispatch on login and revocation on logout.
Example 4: Token sent from backend
# app/controllers/users/sessions_controller.rb
class Users::SessionsController < Devise::SessionsController
respond_to :json
private
def respond_with(resource, _opts = {})
token = request.env['warden-jwt_auth.token']
render json: { message: 'Logged in!', token: token }, status: :ok
end
end
✅ After login, the JWT is returned in JSON response.
Example 5: Request with token header
# Request header from frontend or Postman
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ...
✅ The token is sent on each request and automatically decoded by Devise-JWT.
Example 6: Logout + revoke token
DELETE /logout
Authorization: Bearer eyJhbGciOi...
✅ Devise-JWT adds the token to the denylist, revoking future access.
🔁 Alternatives
has_secure_token
– simpler but less feature-richKnock
gem – another JWT gemDoorkeeper
– OAuth 2.0 support
❓ General Questions & Answers
Q1: What is Devise?
A: Devise is a flexible authentication library for Rails that provides ready-to-use modules for registration, login, logout, password recovery, and more.
Q2: What is JWT?
A: JWT (JSON Web Token) is a compact, URL-safe token format that contains encoded data like user ID and expiration time. It’s commonly used in APIs for stateless authentication.
Q3: Why use Devise-JWT instead of session-based auth?
A: Session-based authentication relies on cookies and server-side sessions, which aren’t suitable for APIs or mobile apps. Devise-JWT offers a stateless, token-based solution ideal for modern APIs.
Q4: How does authentication work with Devise-JWT?
A: After a successful login, Devise returns a JWT to the client. This token is sent with each request in the Authorization
header. Devise-JWT verifies the token and identifies the user.
Q5: Do I need to use cookies?
A: No. Devise-JWT works without cookies. It uses headers, making it ideal for mobile and SPA clients.
Q6: How is logout handled?
A: When the client logs out, the token is revoked (e.g., added to a denylist). Any future use of that token will be rejected.
Q7: Can I use Devise-JWT with frontend frameworks?
A: Absolutely. It works well with React, Vue, Angular, Flutter, and native mobile apps — just store and send the JWT securely in your frontend.
Q8: Where should I store the JWT on the frontend?
A: Store it securely in memory or in secure storage (e.g., SecureStorage
in mobile apps or HttpOnly cookies
if configured safely). Avoid storing tokens in localStorage unless it’s encrypted and short-lived.
🛠️ Deep Dive Technical Q&A
Q1: How does Devise-JWT issue a token?
A: Devise-JWT hooks into Warden. On login, if the request path matches dispatch_requests
, it generates a JWT using a secret and adds it to the response header or lets you access it via warden-jwt_auth.token
.
Q2: How is a user authenticated using JWT?
A: On each request, Devise-JWT reads the token from the Authorization
header, decodes it using the secret key, and sets current_user
based on the payload.
Q3: How can I expire tokens?
A: Set jwt.expiration_time
in seconds (e.g., 1.day.to_i
). Devise-JWT will include an exp claim and deny expired tokens automatically.
Q4: What is a revocation strategy?
A: A revocation strategy determines how tokens are blacklisted (invalidated). Devise-JWT provides:
- Denylist: Save revoked tokens to the database
- Allowlist: Only allow the latest token per user
- None: Stateless only — no revocation
Q5: What is JwtDenylist
and do I need it?
A: Yes, if you’re using the Denylist
strategy. It’s a model that stores invalidated tokens (after logout) and prevents reuse.
Q6: Can I customize JWT claims?
A: Yes! You can override jwt_payload
in the model to add additional claims like role, scope, or user type:
def jwt_payload
super.merge({ role: self.role })
end
Q7: How do I return the token in the JSON response?
A: Access it from the request environment:
request.env['warden-jwt_auth.token']
Q8: Can Devise handle multiple token types (e.g., refresh + access)?
A: Not natively. Devise-JWT supports one token at a time. For refresh/access workflows, consider combining it with custom controllers or using gems like knock
or doorkeeper
.
✅ Best Practices with Examples
1. Use HTTPS in all environments
JWTs contain sensitive user data. Always enforce secure HTTPS connections to prevent man-in-the-middle attacks during login and API access.
2. Store JWTs securely on the client
Mobile apps: use SecureStorage
.
Web apps: prefer HttpOnly
cookies if possible or memory storage for short-lived sessions.
3. Set jwt.expiration_time
and enforce short-lived tokens
Tokens should expire to reduce the risk of reuse after theft.
config.jwt do |jwt|
jwt.expiration_time = 1.hour.to_i
end
4. Use a denylist strategy in production
This prevents old tokens from being reused after logout.
devise :jwt_authenticatable, jwt_revocation_strategy: JwtDenylist
5. Don’t expose the token in logs or error messages
Filter Authorization
header in config/initializers/filter_parameter_logging.rb
:
Rails.application.config.filter_parameters += [:authorization]
6. Disable navigational formats (HTML redirects)
For APIs, you don’t want Devise redirecting — disable HTML format to avoid unwanted behavior:
config.navigational_formats = []
7. Keep the token small — only include essential claims
Use jwt_payload
override to include custom claims only when necessary. Avoid overloading the token with sensitive or bulky data.
8. Invalidate token on critical actions (optional)
For high-security apps, regenerate tokens after changing email, password, or roles:
current_user.update(password: 'newpass')
current_user.jwt_payload # refresh token after change
9. Write request specs that simulate full login/logout flows
Verify token issuance, validity, expiry, and revocation in test scenarios using RSpec or Minitest.
🌍 Real-world Scenario
Imagine you’re building a mobile-first fitness app called FitTrack, with a React Native frontend and a Ruby on Rails backend. The backend is an API-only app using Devise for user authentication and Devise-JWT for token-based security.
✅ Workflow:
-
A user logs in through the mobile app.
POST /login
sends email and password. -
The Rails backend verifies the user with Devise.
If valid, Devise-JWT generates a signed JWT and sends it back. -
The mobile app stores this token securely (in
SecureStorage
). -
For every request to protected endpoints (e.g.,
/profile
,/goals
), the app sends:Authorization: Bearer <JWT>
- Rails reads the JWT, validates it, and authenticates the user without sessions.
-
On logout, the token is revoked and added to a denylist (e.g.,
JwtDenylist
).
💡 Why This Works in Production:
- 🔐 The server doesn’t manage sessions — it scales easily
- 📲 The client holds the token — mobile and web clients stay decoupled
- 🗂️ Multiple teams can work independently (frontend/mobile/backend)
- 🛡️ Token revocation protects from reuse after logout
- ⚡ Fast authentication — no database calls if you’re using stateless tokens
In summary, Devise + Devise-JWT provides a secure, modern way to authenticate users in real-world APIs where scalability, performance, and mobile support are essential.
Custom JWT Authentication (Using jwt
Gem)
🧠 Detailed Explanation
Custom JWT authentication is a stateless and secure method to authenticate users in APIs without using Devise or Rails sessions.
It uses the jwt
gem to generate and validate JSON Web Tokens manually.
🔐 What is a JWT?
A JWT (JSON Web Token) is a compact, URL-safe string that represents a claim or identity. It typically contains a user ID and an expiration time and is cryptographically signed using a secret key.
Example decoded JWT payload:
{
"user_id": 1,
"exp": 1713577200
}
This token is sent from the server to the client after a successful login.
The client stores it and attaches it in the Authorization
header on future requests.
🛠 How it works in Rails (custom implementation):
- 📥 User sends login credentials (
email
andpassword
) - 🔐 If valid, the server generates a JWT using
JsonWebToken.encode
- 📤 The token is returned to the client in the JSON response
- 📦 Client stores the token and sends it in all protected API calls
- 🧠 The server extracts the token on each request, verifies it, and loads the associated user
✨ Benefits of Custom JWT Auth
- 💡 Lightweight — no external authentication libraries
- 📱 Perfect for mobile and SPA clients
- 🧩 You control everything — expiration, payload, headers, refresh logic
- 🛠 Easy to integrate with other systems (microservices, external APIs)
📌 Key Concepts to Remember
- Encoding: JWT is generated with a secret using
JWT.encode
- Decoding: On every request, decode the token using the same secret
- Authorization: You must validate token presence and expiration manually
In short, custom JWT auth with the jwt
gem is a clean and powerful way to authenticate users in APIs.
It’s especially useful when you want control and don’t need the overhead of Devise.
📦 Step-by-Step Implementation
Let’s build a simple and secure JWT authentication system using the jwt
gem in a Rails API-only app.
Step 1: Add the jwt gem
# Gemfile
gem 'jwt'
Then run:
bundle install
Step 2: Create the JWT helper module
# lib/json_web_token.rb
module JsonWebToken
SECRET_KEY = Rails.application.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
body = JWT.decode(token, SECRET_KEY)[0]
HashWithIndifferentAccess.new(body)
rescue
nil
end
end
✅ You can autoload it by adding config.autoload_paths << Rails.root.join("lib")
in application.rb
Step 3: Add has_secure_password
to your User model
# db/migrate
add_column :users, :password_digest, :string
# user.rb
class User < ApplicationRecord
has_secure_password
end
Then run:
rails db:migrate
Step 4: Create a login endpoint
# app/controllers/auth_controller.rb
class AuthController < ApplicationController
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token }, status: :ok
else
render json: { error: "Invalid credentials" }, status: :unauthorized
end
end
end
Step 5: Authenticate requests using the token
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authorize_request
private
def authorize_request
header = request.headers['Authorization']
header = header.split(' ').last if header
decoded = JsonWebToken.decode(header)
@current_user = User.find_by(id: decoded[:user_id]) if decoded
rescue
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
Step 6: Add the login route
# config/routes.rb
post '/login', to: 'auth#login'
Step 7: Send the token in the frontend or Postman
# Example Header
Authorization: Bearer <your_jwt_token>
✅ Now every protected controller will require a valid token to access resources.
💡 Optional Enhancements
- 🕒 Add token expiration handling (auto-refresh if needed)
- 🔁 Implement refresh tokens for long-lived sessions
- 📄 Log login attempts and failed authentications
- 🛡️ Invalidate tokens manually by storing
jti
and checking it
💡 Examples
Example 1: JWT encode & decode helper
# lib/json_web_token.rb
module JsonWebToken
SECRET_KEY = Rails.application.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY)[0]
HashWithIndifferentAccess.new(decoded)
rescue
nil
end
end
✅ This module gives you helper methods to create and decode JWTs.
Example 2: Login controller that issues a token
# app/controllers/auth_controller.rb
class AuthController < ApplicationController
def login
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token, user: user }, status: :ok
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
end
✅ A valid user gets a signed token they can use in future requests.
Example 3: Middleware-like token verification in ApplicationController
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authorize_request
def authorize_request
header = request.headers['Authorization']
token = header.split(' ').last if header
decoded = JsonWebToken.decode(token)
@current_user = User.find_by(id: decoded[:user_id]) if decoded
rescue
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
✅ All requests now check the validity of the JWT before proceeding.
Example 4: Sample API request with token
# Example HTTP request (Postman or JavaScript client)
GET /api/v1/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjox...
✅ The backend reads the token from the Authorization
header.
Example 5: Token expiration handling
JWTs include an exp
claim (expiration time):
payload[:exp] = 24.hours.from_now.to_i
✅ If the token is expired, JWT.decode
raises an exception and the request is blocked.
🔁 Alternatives
Devise + Devise-JWT
(for full-featured auth)Knock
orDoorkeeper
(OAuth2)has_secure_token
(simpler token-based auth)
❓ General Questions & Answers
Q1: What is JWT authentication?
A: JWT (JSON Web Token) authentication is a stateless method for securing APIs. Instead of using cookies or sessions, the server gives the client a signed token that proves the user is authenticated. The client sends this token on every request.
Q2: Why use the jwt
gem in Rails?
A: The jwt
gem gives you full control over token generation, structure, expiration, and validation — without relying on heavy libraries like Devise.
Q3: Where is the token stored?
A: On the client side. For mobile apps: in secure storage. For web apps: in HttpOnly
cookies or browser memory (not localStorage
if possible).
Q4: What goes inside a JWT?
A: A JWT usually contains user-related data like user_id
, along with an exp
(expiration time). It’s signed using a secret so it can’t be tampered with.
Q5: Can I logout a JWT user?
A: Technically, JWT is stateless — once issued, it’s valid until it expires. To “log out,” you either let it expire naturally, rotate the secret, or build a token denylist for invalidation.
Q6: Is this as secure as Devise or session-based auth?
A: Yes, if implemented properly. Use HTTPS, short expiration times, and secure storage. JWTs reduce backend state and scale well for APIs and mobile apps.
Q7: When should I use custom JWT instead of Devise?
A: When you:
- Need lightweight API authentication
- Don’t want cookie/session management
- Want to build your own login logic
- Use third-party clients (React Native, Flutter, etc.)
🛠️ Technical Q&A
Q1: How does JWT ensure integrity and security?
A: JWTs are signed using a secret (or a private key for asymmetric algorithms like RS256). If anyone tries to modify the payload, the signature won’t match and decoding will fail. That’s why you should keep the secret key safe and use HS256
or stronger algorithms.
Q2: Can a JWT be encrypted?
A: By default, JWTs are only signed, not encrypted. This means their payload is base64-encoded but visible if decoded. If you need encryption, consider using JWE
(JSON Web Encryption), which adds an additional layer of confidentiality — though rarely needed in simple API auth flows.
Q3: What happens if the token expires?
A: The exp
(expiration) claim is checked during decoding. If expired, JWT.decode
raises an ExpiredSignature
error. You should catch this and return a 401 (Unauthorized) response.
Q4: How should I handle token refresh?
A: Use a second, longer-lived refresh token. On expiration of the access token, the client uses the refresh token to request a new one. Store refresh tokens securely and invalidate them when reused or expired.
Q5: Can I include custom data in a JWT?
A: Yes! You can include any serializable data in the payload, such as user_id
, role
, or permissions
. Just be cautious not to include sensitive or large data — remember the payload is readable by anyone who decodes the token.
Q6: How do I prevent reuse of old tokens after logout?
A: JWTs are stateless — once issued, they’re valid until they expire. To handle logout securely:
- Keep the token short-lived (e.g., 15–30 min)
- Rotate a global secret key on logout (force-expire all tokens)
- Implement a denylist table if you need to invalidate individual tokens
Q7: How can I decode a JWT safely?
A: Always wrap your decode method with error handling:
begin
payload = JsonWebToken.decode(token)
rescue JWT::ExpiredSignature
render json: { error: 'Token expired' }, status: :unauthorized
rescue JWT::DecodeError
render json: { error: 'Invalid token' }, status: :unauthorized
end
Q8: Can I support multiple clients (admin, API, mobile) with JWT?
A: Yes. You can add custom claims like client: 'admin'
or client: 'mobile'
and apply different rules per role when decoding the payload.
✅ Best Practices
- Use short-lived access tokens and refresh tokens
- Rotate secret keys periodically
- Store JWTs securely on the client
- Never expose tokens in URLs or logs
🌍 Real-world Scenario
You’re building a lightweight API for an e-commerce store. Rather than setting up full authentication with sessions or Devise, you create a simple login route that returns a JWT. The frontend saves the token and sends it with each API call. No sessions, no cookies — just a clean, scalable, stateless API.
Session-based vs Stateless APIs
🧠 Detailed Explanation
In Rails, authentication can be handled in two primary ways: session-based and stateless (token-based). Each has different use cases, behaviors, and trade-offs.
🔐 What is Session-Based Authentication?
This is the traditional Rails approach, where user state is saved on the server after login. The user logs in with their credentials, and Rails stores their user_id
in session[:user_id]
.
- ✅ Maintains user state across requests
- ✅ Uses cookies to store a session ID
- ❌ Requires server-side storage and session management
- ❌ Not ideal for APIs or mobile clients
Example flow:
# User logs in via POST /login
=> server sets session[:user_id] = 1
=> cookie with session ID is stored in browser
# On next request
=> Rails loads user from session[:user_id]
🧪 What is Stateless (Token-Based) Authentication?
Stateless auth means no session or user data is stored on the server. Instead, a signed token (e.g., JWT) is generated and returned to the client, which must include it in each request.
- ✅ Scalable (no server state)
- ✅ Preferred for APIs, SPAs, and mobile apps
- ❌ More responsibility on the client (must store token securely)
- ❌ Logout requires token expiration or revocation strategy
Example flow:
# User logs in via POST /api/v1/login
=> server returns JWT with user_id and exp
# Next request:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
=> server decodes token and verifies user
📊 Comparison Table
Feature | Session-Based | Stateless (JWT) |
---|---|---|
Storage | Server (sessions) | Client (token) |
Maintains Login State | Yes (session ID) | No (each request must send token) |
Logout | Clear session | Expire/revoke token |
Use Case | Web apps | APIs, SPAs, Mobile apps |
🧩 Which One Should You Use?
- Session-based: Best for traditional Rails views and server-rendered HTML apps
- Stateless APIs: Best for decoupled frontends (React, Vue), mobile apps, or third-party API access
✅ In modern Rails apps, it’s common to use both — sessions for internal dashboards and JWT for external API access.
📦 Step-by-Step Implementation
Below are two flows you can implement depending on your use case: Session-based for web apps, and Stateless JWT for APIs. You can also combine them in one Rails app with namespacing.
🔐 1. Session-Based Authentication (Web App)
Step 1: Enable Devise or manual session setup
rails generate devise:install
rails generate devise User
rails db:migrate
Step 2: Default Devise sessions (cookies)
# POST /users/sign_in
session[:user_id] = user.id
# Rails uses cookies to remember the user session
✅ Ideal for browser-based apps where cookies are available.
🧪 2. Stateless JWT Authentication (API)
Step 1: Add jwt
gem
# Gemfile
gem 'jwt'
bundle install
Step 2: Add JWT encode/decode logic
# lib/json_web_token.rb
module JsonWebToken
SECRET_KEY = Rails.application.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
JWT.decode(token, SECRET_KEY)[0]
rescue
nil
end
end
Step 3: API login controller
# app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token }, status: :ok
else
render json: { error: 'Invalid credentials' }, status: :unauthorized
end
end
end
Step 4: Token validation on API requests
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
before_action :authenticate_api_user
def authenticate_api_user
auth_header = request.headers['Authorization']
token = auth_header.split(' ').last if auth_header
decoded = JsonWebToken.decode(token)
@current_user = User.find(decoded['user_id']) if decoded
rescue
render json: { error: 'Unauthorized' }, status: :unauthorized
end
end
Step 5: Namespacing routes
# config/routes.rb
devise_for :users # for web
namespace :api do
namespace :v1 do
post 'login', to: 'sessions#create'
end
end
🌐 Final Setup Example:
/users/sign_in
→ Session-based login (web)/api/v1/login
→ JWT-based login (mobile or API)
✅ This allows your Rails app to handle both traditional session-based users (for dashboards) and token-based users (for apps or APIs).
💡 Examples
Example 1: Session-based login (traditional Rails)
# config/routes.rb
resources :sessions, only: [:create, :destroy]
# 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
render json: { message: "Logged in" }
else
render json: { error: "Invalid credentials" }, status: :unauthorized
end
end
def destroy
reset_session
render json: { message: "Logged out" }
end
end
✅ This uses cookies and Rails’ built-in session
object.
Example 2: Stateless login using JWT
# config/routes.rb
namespace :api do
namespace :v1 do
post 'login', to: 'sessions#create'
end
end
# app/controllers/api/v1/sessions_controller.rb
class Api::V1::SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user&.authenticate(params[:password])
token = JsonWebToken.encode(user_id: user.id)
render json: { token: token }, status: :ok
else
render json: { error: "Invalid credentials" }, status: :unauthorized
end
end
end
✅ Token is sent back to the client and stored manually.
Example 3: Securing requests using sessions (session-based)
class ApplicationController < ActionController::Base
before_action :require_login
def require_login
redirect_to login_path unless session[:user_id]
end
end
Example 4: Securing requests using JWT (stateless)
class ApplicationController < ActionController::API
before_action :authenticate_api_user
def authenticate_api_user
token = request.headers['Authorization']&.split(' ')&.last
decoded = JsonWebToken.decode(token)
@current_user = User.find(decoded['user_id']) if decoded
rescue
render json: { error: "Unauthorized" }, status: :unauthorized
end
end
✅ Stateless requests do not require cookies — just a valid token in headers.
Example 5: Token-based logout
# Option 1: Let it expire
# Option 2: Manually implement a denylist:
POST /logout
=> add token to a denylist DB table
✅ JWT doesn’t have a built-in logout — you manage that yourself.
🔁 Alternatives
- Cookie-based auth with JWT (hybrid approach)
- OAuth2 / API keys for 3rd-party clients
❓ General Questions & Answers
Q1: What’s the difference between session-based and stateless APIs?
A: Session-based APIs store user state (like login status) on the server using cookies. Stateless APIs don’t store anything between requests — every request must include all necessary data, typically using a token.
Q2: Which is better for APIs?
A: Stateless APIs are better for mobile apps, SPAs, and public APIs because they scale better and don’t depend on cookies or server memory. Session-based is best for full Rails web apps with views and forms.
Q3: Can I use both in one Rails application?
A: Yes! You can use session-based login for your admin dashboard and token-based login for your API. Just namespace your routes and apply different auth logic to each.
Q4: Do session-based APIs work with mobile apps?
A: Not easily. Mobile apps and third-party clients don’t handle cookies well. Token-based (stateless) auth is more compatible and secure for those clients.
Q5: Does Devise support stateless APIs?
A: Yes. While Devise is session-based by default, you can use devise-jwt
to turn it into a stateless token-based system.
Q6: How does logout work in stateless APIs?
A: There’s no “session” to destroy. Logout is done by letting the token expire or storing used tokens in a denylist and blocking them on the server.
Q7: Can I use JWTs in browser-based apps?
A: Yes, but use them with caution. Storing JWTs in localStorage can expose them to XSS. Consider HttpOnly
cookies for extra security or use memory storage with automatic expiration.
🛠️ Technical Q&A
Q1: How does Rails manage sessions under the hood?
A: Rails uses Rack middleware to manage sessions. A session ID is stored in a cookie, and Rails retrieves the session hash from a store (like memory, Redis, or database) using that ID on each request.
Q2: What happens in API-only mode?
A: In API-only Rails apps (--api
flag), Rails disables session and cookie middleware by default. This makes it stateless. You can re-enable sessions manually if needed, but it goes against API design principles.
Q3: How does JWT ensure stateless authentication?
A: JWT tokens are signed and encoded strings. They contain all necessary user info (e.g., user_id, expiration). Rails decodes the token on each request and does not persist anything on the server.
Q4: How can I detect if a request is coming from a session-based client?
A: You can check if session[:user_id]
is present or if request.format.html?
is true (for browser-based clients). APIs typically expect application/json
and include tokens in headers.
Q5: Can stateless APIs handle CSRF protection?
A: No — CSRF protection only applies to session-based clients using cookies. Since stateless APIs do not use cookies, they are immune to CSRF but should validate tokens strictly.
Q6: What’s the memory impact of each approach?
A: Session-based auth can use significant memory if many users are logged in and sessions are stored in-memory or database. Stateless APIs don’t store anything on the server, so they scale better with less memory usage.
Q7: Can I use Redis to store both sessions and token revocation?
A: Yes. Redis is a good option for storing:
- Sessions for session-based apps
- Denylist tokens (e.g., revoked JWTs) for stateless APIs
Q8: Can I share authentication logic across both web and API?
A: Yes. You can define shared methods in ApplicationController
and apply different before_actions in BaseController
for web (using sessions) and Api::BaseController
for APIs (using tokens).
✅ Best Practices
- Use sessions for server-rendered web apps with login
- Use stateless tokens for SPAs, mobile apps, or APIs
- Keep tokens short-lived and rotate them regularly
- Do not store sensitive data in JWTs
🌍 Real-world Scenario
You’re building a multi-platform SaaS application. Your admin dashboard is a traditional Rails web app using session-based login, while your public API (consumed by mobile apps and third-party clients) uses JWT-based stateless authentication. You handle both flows in the same codebase using namespaces like /admin
and /api/v1
.
Pundit or CanCanCan (Authorization in Rails)
🧠 Detailed Explanation
In Rails, authorization determines what a user is allowed to do after they’ve been authenticated. Two of the most popular gems for managing permissions are: Pundit and CanCanCan.
🔒 What is Pundit?
Pundit is a minimalist authorization library. It uses plain Ruby classes called Policies to control access at the model level.
Each model has its own policy class (e.g., PostPolicy
), and each method inside that class corresponds to a controller action like show?
, update?
, or destroy?
.
Advantages of Pundit:
- ✅ Clear separation per model
- ✅ Simple and readable Ruby logic
- ✅ Explicit calls to
authorize
andpolicy_scope
encourage clarity
⚙️ What is CanCanCan?
CanCanCan is a more declarative and centralized authorization gem. It uses a single Ability
class where you define what a user can or cannot do across all models.
It works seamlessly with load_and_authorize_resource
to automatically handle access control in your controllers.
Advantages of CanCanCan:
- ✅ Centralized permission definitions
- ✅ Auto-loading and checking access in controllers
- ✅ Useful for apps with roles (admin, editor, viewer)
🔁 Key Differences
Aspect | Pundit | CanCanCan |
---|---|---|
Structure | Policy per model | Single Ability class |
Style | Explicit, verbose | Declarative, concise |
Controller Integration | authorize & policy_scope |
load_and_authorize_resource |
Best for | Fine-grained control | Role-based permissions |
🔍 Summary
– Use Pundit if you prefer clear policies per model, especially in apps with more complex, context-based permissions.
– Use CanCanCan if you want centralized control and automatic controller integration, especially in apps with role-based access.
📦 Step-by-Step Implementation
This guide outlines both Pundit and CanCanCan setups. Choose one based on your project style.
🔐 Option 1: Using Pundit
Step 1: Add the gem
# Gemfile
gem 'pundit'
bundle install
Step 2: Install it
rails generate pundit:install
Step 3: Create a policy
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def update?
user.admin? || user.id == record.user_id
end
end
Step 4: Use in controller
class PostsController < ApplicationController
include Pundit
def update
@post = Post.find(params[:id])
authorize @post
@post.update(post_params)
end
end
Step 5: Use policy scope
def index
@posts = policy_scope(Post)
end
🛠 Option 2: Using CanCanCan
Step 1: Add the gem
# Gemfile
gem 'cancancan'
bundle install
Step 2: Generate Ability class
rails generate cancan:ability
Step 3: Define rules
# app/models/ability.rb
class Ability
include CanCan::Ability
def initialize(user)
return unless user
if user.admin?
can :manage, :all
else
can :update, Post, user_id: user.id
can :read, Post
end
end
end
Step 4: Use in controllers
class PostsController < ApplicationController
load_and_authorize_resource
def update
@post.update(post_params)
end
end
Step 5: Handle unauthorized errors
# app/controllers/application_controller.rb
rescue_from CanCan::AccessDenied do |exception|
render json: { error: exception.message }, status: :forbidden
end
📌 Summary
- Pundit: Good for model-specific policies, explicit usage
- CanCanCan: Good for role-based or centralized permissions
Both work well with Devise or custom auth setups!
💡 Examples
🔐 Pundit Example
Step 1: Policy file (PostPolicy)
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def update?
user.admin? || user.id == record.user_id
end
def destroy?
user.admin?
end
class Scope < Scope
def resolve
user.admin? ? scope.all : scope.where(user_id: user.id)
end
end
end
Step 2: Controller usage
class PostsController < ApplicationController
include Pundit
def update
@post = Post.find(params[:id])
authorize @post
@post.update!(post_params)
end
def index
@posts = policy_scope(Post)
end
end
Step 3: View usage
<% if policy(post).update? %>
<%= link_to 'Edit', edit_post_path(post) %>
<% end %>
🛠 CanCanCan Example
Step 1: Ability class
# app/models/ability.rb
class Ability
include CanCan::Ability
def initialize(user)
return unless user
if user.admin?
can :manage, :all
else
can :read, Post
can :update, Post, user_id: user.id
end
end
end
Step 2: Controller usage
class PostsController < ApplicationController
load_and_authorize_resource
def update
@post.update!(post_params)
end
end
Step 3: View usage
<% if can? :update, post %>
<%= link_to 'Edit', edit_post_path(post) %>
<% end %>
📝 Result:
- Pundit: More explicit, control per policy class
- CanCanCan: Centralized, cleaner view integration
✅ Both support scoping, custom error handling, and integration with Devise or current_user logic.
🔁 Alternatives
- ActionPolicy — Modern alternative with faster performance
- Custom helper methods in controllers (minimal use cases)
❓ General Questions & Answers
Q1: What is the difference between authentication and authorization?
A: Authentication confirms “who” a user is (login/signup). Authorization controls “what” the user is allowed to do (view, edit, delete). Pundit and CanCanCan handle authorization.
Q2: Which gem is better — Pundit or CanCanCan?
A: Both are great, but their style is different. Use Pundit if you want per-model, plain Ruby policies. Use CanCanCan if you want centralized, role-based permissions in one class.
Q3: Can I use both Pundit and CanCanCan together?
A: Technically yes, but it’s not recommended. It leads to confusion and duplication. Stick to one gem per project for clarity.
Q4: Can I use these with Devise?
A: Absolutely. Both Pundit and CanCanCan integrate well with Devise. You can use current_user
in your policy or ability files to check roles and permissions.
Q5: Are these gems only for APIs?
A: No. Both gems work with full Rails apps, APIs, or even in hybrid setups. Pundit works well in both server-rendered and API-only apps, while CanCanCan also supports JSON API authorization checks.
Q6: Do they support role-based access?
A: Yes. CanCanCan is role-focused out of the box (e.g., admin/editor/viewer logic). Pundit can handle roles too but requires writing the logic inside each policy method.
Q7: Can I restrict access in views too?
A: Yes! Both gems provide helpers:
policy(record).action?
for Punditcan?(:update, record)
for CanCanCan
Q8: What happens if a user accesses something they shouldn’t?
A: Both gems raise exceptions (e.g., Pundit::NotAuthorizedError
or CanCan::AccessDenied
), which you should rescue globally to show a proper error page or JSON response.
🛠️ Technical Q&A
Q1: How does Pundit determine authorization?
A: Pundit uses plain Ruby classes called Policy
files. When you call authorize(@post)
, Rails looks for PostPolicy
and checks the current user’s permissions using a method like update?
.
Q2: How does CanCanCan enforce permissions?
A: CanCanCan uses a central Ability
class where all permissions are declared using can
and cannot
. It uses method chaining and automatic controller hooks (like load_and_authorize_resource
) to authorize and load records.
Q3: What is the difference between authorize
and policy_scope
in Pundit?
A: authorize
checks whether the user is allowed to perform a specific action on a single object. policy_scope
applies scoping logic (e.g., only showing their own records) to a collection of records, often used in index
actions.
Q4: How does CanCanCan handle authorization for index
actions?
A: You use accessible_by(current_ability)
to fetch only records the user has permission to read. If you’re using load_and_authorize_resource
, it applies automatically based on defined rules.
Q5: Can I test policies or abilities?
A: Yes. For Pundit, write unit tests for each policy class and method. For CanCanCan, use specs to test your Ability
rules with different user roles. Both can also be tested in controller/request specs by simulating access attempts.
Q6: What happens if authorization fails?
A: Pundit raises Pundit::NotAuthorizedError
and CanCanCan raises CanCan::AccessDenied
. You should rescue these in ApplicationController
and render a friendly error page or JSON response.
Q7: Can I override default behavior per controller?
A: Yes. In Pundit, you can skip authorize
or customize logic with policies per controller. In CanCanCan, you can override how load_resource
finds records or skip authorization for specific actions.
Q8: Can these gems work with namespaced resources (e.g., Admin::Post
)?
A: Yes. Pundit allows namespaced policy classes (like Admin::PostPolicy
), and CanCanCan supports scoped models by passing the class name explicitly in can
definitions.
✅ Best Practices
🔐 General Authorization Best Practices
- ✅ Always authorize every action
Whether usingauthorize
(Pundit) orload_and_authorize_resource
(CanCanCan), ensure every protected action checks permissions. - ✅ Scope database access
Usepolicy_scope
in Pundit oraccessible_by
in CanCanCan to limit records to what the user is allowed to see — especially onindex
pages. - ✅ Handle unauthorized access gracefully
Show a friendly error or redirect with a message if a user tries to access something they shouldn’t. Avoid exposing sensitive logic. - ✅ Test your authorization
Write request specs or policy specs to ensure users with specific roles can (or cannot) perform certain actions. - ✅ Avoid logic duplication
Centralize logic in your policy or ability files — not in controllers or views. - ✅ Keep your policies/abilities clean and focused
If a file gets too big (e.g., Ability), split logic using helper methods or concerns.
📘 Pundit-Specific Best Practices
- ✅ Use
ApplicationPolicy
as a base class for shared logic. - ✅ Group related policies by context (e.g.,
Admin::PostPolicy
). - ✅ Use
policy_scope
for index/show-all queries — don’t rely on controller filters alone. - ✅ Keep policies small — one method per action (
update?
,destroy?
, etc.).
📗 CanCanCan-Specific Best Practices
- ✅ Keep
Ability
class organized using role-specific blocks. - ✅ Use
can :manage, :all
for admins, but avoid overuse to prevent security issues. - ✅ Use
alias_action
to reduce repetition if actions share rules (e.g.,alias_action :update, :destroy, to: :modify
). - ✅ Use
accessible_by
withload_and_authorize_resource
for clean controller queries.
📌 Summary
No matter which gem you choose, the goal is the same: ensure users only access what they’re allowed to. Choose Pundit for flexibility and readability, or CanCanCan for declarative roles and auto-loading — but always be explicit, test thoroughly, and keep your logic clear and DRY.
🌍 Real-world Scenario
Let’s say you’re building a SaaS app for a content management system (CMS). There are three types of users:
- Admin – can manage everything
- Editor – can create and update posts, but not delete
- Viewer – can only read content
🔐 Using Pundit
You define a PostPolicy
with methods like create?
, update?
, and destroy?
. Inside each method, you write logic such as:
def destroy?
user.admin?
end
def update?
user.admin? || user.editor?
end
Then in your controller:
def update
@post = Post.find(params[:id])
authorize @post # checks update?
@post.update(post_params)
end
This gives you per-model, per-action control with readable logic, especially as the app grows.
🛠 Using CanCanCan
In your Ability
class, you define permissions based on roles:
if user.admin?
can :manage, :all
elsif user.editor?
can [:read, :create, :update], Post
elsif user.viewer?
can :read, Post
end
In your controller:
class PostsController < ApplicationController
load_and_authorize_resource
end
CanCanCan automatically loads the post and checks if the user can perform the requested action.
🧠 Why This Matters
- 🧩 Prevents unauthorized data manipulation
- 🛡 Provides clear, maintainable access control
- 🚀 Scales well with growing roles and features
Whether you choose Pundit for flexibility or CanCanCan for centralization, both are widely used in production Rails apps like admin panels, marketplaces, blogging platforms, and SaaS dashboards.
Role-Based Access Control (RBAC)
🧠 Detailed Explanation
Role-Based Access Control (RBAC) is a common security pattern that assigns permissions based on user roles. Instead of granting permissions to individual users, you assign each user a role, and each role has defined access to specific actions or resources.
🔐 Why use RBAC?
- ✅ Easier to manage permissions (especially in large apps)
- ✅ Clearer logic: actions are grouped by roles, not individual users
- ✅ Consistent and scalable access control
- ✅ Reduces duplication — one place to manage rules per role
📋 How it works in Rails
In Rails, RBAC is commonly implemented in one of these ways:
- Enum roles: You define simple roles using ActiveRecord enums (e.g.,
:admin
,:editor
,:viewer
) - Role model: A more flexible setup with a separate
roles
table andhas_many
associations - Gems: Use Pundit or CanCanCan to handle role permissions in a clean, reusable way
🧠 Example Roles
Common roles in a typical application might include:
admin
– full control, can manage users and settingseditor
– can create/edit content but not manage usersviewer
– can only read content
🚦 Where is RBAC used in Rails?
- Controllers: Prevent access to actions (e.g.,
before_action :require_admin!
) - Policies: Define which roles can perform specific actions on models
- Views: Show/hide buttons, links, and pages based on the current user’s role
🧩 Enum-based vs Role Model
Feature | Enum Roles | Role Model |
---|---|---|
Simplicity | ✅ Very easy | ❌ More setup |
Supports multiple roles | ❌ Not easily | ✅ Yes |
Performance | ✅ Fast (integer-based) | ⚠️ Slightly slower (joins) |
Flexibility | ❌ Limited | ✅ Highly customizable |
🎯 When to use RBAC
- Multi-user systems (admin panel, CMS, API dashboards)
- Any system with content moderation, editorial workflow, or internal tools
- Where permission management needs to be centralized and scalable
✅ RBAC keeps your Rails application secure, structured, and easier to maintain as it grows.
📦 Step-by-Step Implementation
This example uses enum-based roles in the User
model and applies those roles using conditionals, Pundit, or CanCanCan for a robust and testable RBAC system.
👤 Step 1: Add role column to users
# db/migrate/xxxxxx_add_role_to_users.rb
class AddRoleToUsers < ActiveRecord::Migration[7.0]
def change
add_column :users, :role, :integer, default: 0
end
end
# Then run:
rails db:migrate
📦 Step 2: Define enum roles in User model
# app/models/user.rb
class User < ApplicationRecord
enum role: { viewer: 0, editor: 1, admin: 2 }
# Optional: set default role
after_initialize do
self.role ||= :viewer if self.new_record?
end
end
🔐 Step 3: Enforce roles in controllers (manual way)
# app/controllers/application_controller.rb
def require_admin!
redirect_to root_path, alert: "Access denied" unless current_user.admin?
end
# Example usage
before_action :require_admin!, only: [:destroy]
🧠 Step 4: Optional — Use with Pundit or CanCanCan
In Pundit policy:
# app/policies/post_policy.rb
def update?
user.admin? || user.editor?
end
In CanCanCan ability:
# app/models/ability.rb
can :manage, :all if user.admin?
can [:read, :update], Post if user.editor?
can :read, Post if user.viewer?
🧪 Step 5: Add RBAC logic to views
<% if current_user.admin? %>
<%= link_to "Delete", post_path(post), method: :delete %>
<% end %>
<% if policy(post).update? %>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
🛠️ Step 6: Seed default users with roles
# db/seeds.rb
User.create!(email: "admin@example.com", password: "password", role: :admin)
User.create!(email: "editor@example.com", password: "password", role: :editor)
User.create!(email: "viewer@example.com", password: "password", role: :viewer)
🎯 Summary
- Use
enum
for simple role management - Abstract permissions using
policy
orability
files - Validate access in controllers AND views
- Use seeds to quickly set up test users
💡 Examples
Example 1: Enum roles in User model
# app/models/user.rb
class User < ApplicationRecord
enum role: { viewer: 0, editor: 1, admin: 2 }
end
This allows you to call methods like user.admin?
, user.editor?
, and User.admin!
.
Example 2: Controller-level restriction
# app/controllers/posts_controller.rb
before_action :require_admin!, only: [:destroy]
def require_admin!
redirect_to root_path, alert: "Access Denied" unless current_user.admin?
end
✅ A quick way to protect actions from non-admins.
Example 3: Pundit policy with role logic
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def update?
user.admin? || user.editor?
end
def destroy?
user.admin?
end
end
✅ Clean and scalable rule separation per model/action.
Example 4: CanCanCan ability with role logic
# app/models/ability.rb
class Ability
include CanCan::Ability
def initialize(user)
return unless user
if user.admin?
can :manage, :all
elsif user.editor?
can [:read, :create, :update], Post
elsif user.viewer?
can :read, Post
end
end
end
✅ All roles and permissions in one centralized file.
Example 5: View logic using roles
<% if current_user.admin? %>
<%= link_to "Delete", post_path(post), method: :delete %>
<% end %>
<% if current_user.editor? || current_user.admin? %>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
✅ Keep your UI in sync with user permissions.
Example 6: Auto-assign default role on signup
# app/models/user.rb
after_initialize do
self.role ||= :viewer if self.new_record?
end
✅ Prevents blank roles and ensures predictable permissions.
🔁 Alternatives
- Attribute-based access control (ABAC)
- Permission tables (fine-grained control)
- Hardcoded checks in controller (not recommended)
❓ General Questions & Answers
Q1: What is Role-Based Access Control (RBAC)?
A: RBAC is a method of restricting system access based on a user’s role. Each role has defined permissions, and users are assigned one or more roles. This simplifies authorization management.
Q2: Why is RBAC better than per-user permissions?
A: RBAC is more scalable and maintainable. Instead of managing permissions for every user individually, you define roles (e.g., admin, editor) and assign those roles to users.
Q3: How do I store roles in Rails?
A: The simplest way is using enum
in the User model. For more complex apps, use a separate Role
model with a has_many :through
relationship to support multiple roles per user.
Q4: Can users have multiple roles?
A: Yes, but not with enums. To support multiple roles, you’ll need a Role model and a join table like UserRole
to associate users and roles.
Q5: Do I need Pundit or CanCanCan for RBAC?
A: Not necessarily. You can enforce role logic with simple conditionals. However, Pundit and CanCanCan help organize and scale your permission logic as the app grows.
Q6: How do I show/hide UI elements based on roles?
A: Use helpers in views like:
<% if current_user.admin? %>
or can? :edit, post
if using CanCanCan.
Q7: Should I assign a default role to new users?
A: Yes. It’s a best practice to assign a safe default role like :viewer
when creating a new user. You can do this in a model callback or controller logic.
Q8: Where should I put RBAC logic — controller or model?
A: Ideally, abstract your access control logic into policy classes (Pundit), ability files (CanCanCan), or service objects. This keeps your controllers thin and readable.
🛠️ Technical Q&A
Q1: What’s the difference between enum-based roles and a Role model?
A:
- Enum: Simple, fast, stored as an integer in the
users
table. One role per user only. - Role model: Flexible, allows multiple roles per user through a join table. Slightly more complex but ideal for large apps.
Q2: How do I scope records based on roles?
A: You can use policy_scope
in Pundit, accessible_by
in CanCanCan, or define custom scopes in your models. Example:
def self.visible_to(user)
user.admin? ? all : where(published: true)
end
Q3: How can I make roles available in views?
A: Simply use role-checking helpers like current_user.admin?
or can?(:edit, post)
in your views to control button visibility, links, and navigation items.
Q4: What’s the performance difference between enum vs Role model?
A:
- Enums: Extremely fast since it’s just an integer lookup.
- Role model: Slightly slower due to SQL joins but provides greater flexibility for dynamic permissions and multiple roles.
Q5: How do I test RBAC rules?
A: Use RSpec or Minitest to create users with specific roles and simulate various scenarios. Example:
it "prevents editors from deleting posts" do
user = create(:user, role: :editor)
post = create(:post)
expect(PostPolicy.new(user, post).destroy?).to eq(false)
end
Q6: Can I change a user’s role dynamically?
A: Yes. For enums: user.admin!
or user.role = :admin
. For Role models: use associations like user.roles << Role.find_by(name: "editor")
.
Q7: How do I integrate roles into authentication?
A: Use Devise or similar gems for authentication, then apply RBAC through policies or filters based on current_user.role
. RBAC complements, not replaces, authentication.
Q8: Can I combine RBAC with feature toggles?
A: Yes! You can use a gem like Flipper
or custom logic to enable/disable features per role, giving you even more control over access and visibility.
✅ Best Practices
1. Use Enums for Simplicity (if you only need one role per user)
Enums are easy to use, fast, and integrate naturally into ActiveRecord.
Use them like: user.admin?
or user.editor!
.
2. Use a Role model with many-to-many association for multiple roles
If users can have more than one role (e.g., moderator
+ editor
), create a Role
model and a UserRole
join table.
3. Set a default role during user creation
# app/models/user.rb
after_initialize do
self.role ||= :viewer if new_record?
end
🔐 This prevents users from accidentally being created without a role.
4. Avoid hardcoding roles in controllers
Instead of using if current_user.admin?
in multiple places,
centralize logic in:
Policies
(Pundit)Abilities
(CanCanCan)- Service Objects or decorators
5. Always authorize access on both the backend and the frontend
Do not rely on hiding buttons in the view. Always protect sensitive actions in controllers too with proper authorization logic.
6. Use role helpers in views sparingly
<% if current_user.admin? %>
<%= link_to "Admin Settings", admin_settings_path %>
<% end %>
✅ It’s okay for conditional UI, but don’t let this replace proper controller security.
7. Document your roles and permissions
Keep a table or markdown doc showing which roles have access to which features. It helps your team and makes onboarding easier.
8. Test role-based access thoroughly
Write request specs or policy/ability specs to ensure the right roles have the right access. It’s easy to overlook permission leaks without tests.
9. Use scopes for index/list filtering
Define methods like Post.visible_to(user)
to prevent users from seeing unauthorized records.
10. Combine RBAC with feature flags if needed
Use gems like flipper
to turn features on/off per role or environment. This adds flexibility for beta testing, phased rollouts, or A/B testing.
🌍 Real-world Scenario
You’re building a SaaS product — a project collaboration platform like Asana or Trello. Your app has three user roles:
- Admin: Can manage users, projects, settings
- Project Manager (Editor): Can create/update tasks and assign users
- Team Member (Viewer): Can view assigned tasks and add comments
🎯 Implementation with Enums
# app/models/user.rb
enum role: { viewer: 0, editor: 1, admin: 2 }
# app/controllers/projects_controller.rb
before_action :authorize_admin!, only: [:destroy]
def authorize_admin!
redirect_to root_path, alert: "Not authorized" unless current_user.admin?
end
✅ Admins can delete projects, editors and viewers cannot. The logic is centralized and clear.
🧠 Implementation with Pundit
# app/policies/project_policy.rb
def update?
user.admin? || user.editor?
end
def destroy?
user.admin?
end
# app/controllers/projects_controller.rb
def update
@project = Project.find(params[:id])
authorize @project
@project.update!(project_params)
end
✅ Now your logic is reusable across the app — including in background jobs, API controllers, and admin dashboards.
🧪 Testing the Roles
it "prevents viewer from updating a project" do
viewer = create(:user, role: :viewer)
project = create(:project)
policy = ProjectPolicy.new(viewer, project)
expect(policy.update?).to be_falsey
end
📌 Outcome
- 🔐 Admins manage the whole platform
- ✅ Editors manage content only
- 👀 Viewers can’t do destructive actions
- 📦 All logic is testable, reusable, and consistent
✅ This approach keeps the system secure, clean, and easy to scale as you add more roles like billing_manager
or guest
.
Resource-Based Permissions
🧠 Detailed Explanation
Resource-Based Permissions control what a user can do with specific records (resources), not just what they can do in general based on their role.
For example: even if a user has the role editor
, they may only be allowed to edit posts that they created — not all posts in the system.
🛡️ Why It Matters
- ✅ Prevents users from accessing or modifying data they don’t own
- ✅ Adds an extra layer of security beyond roles
- ✅ Enables fine-grained control (per-record decisions)
- ✅ Critical in multi-tenant systems or collaborative apps
📦 How It Works
Every time a user tries to perform an action (like view, edit, or delete), the app checks:
record.user_id == current_user.id
This logic can be placed in:
- ✔️
Pundit
policies - ✔️
CanCanCan
abilities - ✔️ Custom service objects
🔍 Where It’s Used
- Multi-user systems – users can only access their own dashboards, profiles, or posts
- Educational platforms – students can only view their own assignments or grades
- Project tools – team members can only edit tasks assigned to them
- Marketplaces – sellers can only manage their own products
🔄 Role vs Resource Permissions
Feature | Role-Based | Resource-Based |
---|---|---|
Scope | Global access per user | Per record, per action |
Example Rule | Admin can delete anything | User can only edit their own post |
Where Used | Navigation, admin access | Controllers, policies, data filtering |
Tools | Enums, Devise, CanCanCan | Pundit, Scopes, Service Objects |
📌 Final Thought
Resource-based permissions ensure that users only access what’s truly theirs. When combined with role-based systems, they provide powerful, secure access control in Rails applications.
📦 Step-by-Step Implementation
This example walks through how to restrict access so users can only interact with the resources they “own.”
🧱 Step 1: Add ownership to your model
# db/migrate/xxxx_add_user_id_to_posts.rb
add_reference :posts, :user, foreign_key: true
# app/models/post.rb
belongs_to :user
Each post now belongs to a specific user — useful for ownership checks.
🔐 Step 2: Use Pundit for resource authorization
# app/policies/post_policy.rb
class PostPolicy < ApplicationPolicy
def show?
user.admin? || record.user_id == user.id
end
def update?
user.admin? || record.user_id == user.id
end
def destroy?
user.admin? || record.user_id == user.id
end
class Scope < Scope
def resolve
user.admin? ? scope.all : scope.where(user_id: user.id)
end
end
end
🧠 Step 3: Authorize records in controllers
# app/controllers/posts_controller.rb
def show
@post = Post.find(params[:id])
authorize @post
end
def index
@posts = policy_scope(Post)
end
def update
@post = Post.find(params[:id])
authorize @post
@post.update!(post_params)
end
✅ This ensures unauthorized access is blocked before reaching sensitive actions.
✅ Step 4: Enforce logic in views
<% if policy(post).update? %>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
🔐 Prevents users from seeing links to actions they can’t perform.
🧪 Step 5: Test permissions
# spec/policies/post_policy_spec.rb
describe PostPolicy do
subject { described_class }
let(:user) { FactoryBot.create(:user) }
let(:other_user) { FactoryBot.create(:user) }
permissions :update? do
it "grants access to owner" do
post = FactoryBot.create(:post, user: user)
expect(subject).to permit(user, post)
end
it "denies access to others" do
post = FactoryBot.create(:post, user: other_user)
expect(subject).not_to permit(user, post)
end
end
end
✅ Always test both allowed and denied cases.
📌 Summary
- Attach resources to users via
user_id
- Use
record.user_id == user.id
in policies - Call
authorize
andpolicy_scope
in controllers - Hide UI actions users can’t perform
- Test your permissions
💡 Examples
Example 1: Only let users edit their own posts (Manual Check)
# app/controllers/posts_controller.rb
def edit
@post = Post.find(params[:id])
redirect_to root_path unless @post.user_id == current_user.id
end
❗ Quick and simple — but hard to maintain if used everywhere.
Example 2: Using Pundit to authorize resources
# app/policies/post_policy.rb
def update?
user.admin? || record.user_id == user.id
end
# app/controllers/posts_controller.rb
def update
@post = Post.find(params[:id])
authorize @post
@post.update!(post_params)
end
✅ Clean and reusable. Centralizes access logic.
Example 3: Using CanCanCan for resource ownership
# app/models/ability.rb
can :update, Post, user_id: user.id
can :destroy, Post, user_id: user.id
# app/controllers/posts_controller.rb
load_and_authorize_resource
✅ Automatically loads and checks access rights.
Example 4: Show “Edit” link only to owners
<% if policy(post).update? %>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
# OR with CanCanCan
<% if can? :update, post %>
<%= link_to "Edit", edit_post_path(post) %>
<% end %>
✅ Controls UI access as well as backend enforcement.
Example 5: Scope data to only what the user owns
# Pundit scope
class PostPolicy::Scope < Scope
def resolve
user.admin? ? scope.all : scope.where(user_id: user.id)
end
end
# In controller
@posts = policy_scope(Post)
✅ Prevents listing resources users shouldn’t even see.
🔁 Alternatives
- Global roles only (RBAC) — simpler, but less secure
- Feature flags — for toggling actions, not per-record access
❓ General Questions & Answers
Q1: What are resource-based permissions?
A: Resource-based permissions control access to individual records (e.g., a post, task, or project), not just general features. For example, a user may have access to edit their own posts but not others.
Q2: How is this different from role-based access control (RBAC)?
A: RBAC grants permissions based on user roles (e.g., admin, editor), while resource-based permissions add a layer that checks if a user can access a specific record. You can use both together.
Q3: Do I need a gem to implement this?
A: No. You can implement simple ownership checks manually, but using gems like Pundit
or CanCanCan
helps keep authorization logic clean and reusable.
Q4: Can I combine resource permissions with roles?
A: Yes. A common pattern is: roles determine what types of actions are allowed, and resource checks determine if the user can do that action on a specific record.
Q5: How do I apply these checks in Rails?
A: In your controller, use:
authorize @record # for Pundit
can? :edit, @record # for CanCanCan
These prevent unauthorized access on a per-record basis.
Q6: Where should I place this logic?
A: Ideally in:
- Policy classes if you’re using Pundit
- Ability class if you’re using CanCanCan
- Custom service objects for more complex logic
Q7: Can I use this approach in APIs?
A: Absolutely. In API-only Rails apps, resource-based permissions help ensure users can’t fetch or update someone else’s data. Just call authorize resource
before rendering.
Q8: How do I prevent users from seeing unauthorized data in lists?
A: Use policy_scope
(Pundit) or accessible_by
(CanCanCan) to filter records based on the user’s access level.
🛠️ Technical Q&A
Q1: How does Pundit evaluate resource permissions?
A: When you call authorize(@record)
, Pundit looks for a matching policy (e.g., PostPolicy
) and checks if the current_user is allowed to perform the action, using methods like update?
or destroy?
.
Q2: How does CanCanCan enforce ownership rules?
A: In the Ability
class, you define ownership rules like:
can :update, Post, user_id: user.id
Then CanCanCan automatically applies these when you use load_and_authorize_resource
in controllers.
Q3: How do I filter records by permission?
A: Use policy scopes (Pundit) or access scopes (CanCanCan) to filter data:
# Pundit
@posts = policy_scope(Post)
# CanCanCan
@posts = Post.accessible_by(current_ability)
This prevents unauthorized records from appearing in lists or APIs.
Q4: Can I use resource-based permissions with Devise?
A: Yes. Devise handles authentication (current_user
), and you can use that inside policies or abilities to restrict access based on the resource’s owner.
Q5: How do I test resource-based policies?
A: You can write policy specs or request specs:
it "prevents other users from editing the post" do
expect(PostPolicy.new(other_user, post).update?).to be false
end
You should also test that unauthorized users get redirected or blocked when accessing resources they don’t own.
Q6: How do I secure API endpoints with resource-level access?
A: Always use authorize
before rendering or updating:
def show
post = Post.find(params[:id])
authorize post
render json: post
end
Never rely on frontend checks alone for protection.
Q7: What if a user tries to guess another resource ID in the URL?
A: As long as you’re using authorize
or load_and_authorize_resource
in your controller, the unauthorized access will be blocked with a 403 or redirect.
Q8: Is it better to use policies or conditionals in controllers?
A: Policies (Pundit) or abilities (CanCanCan) are more maintainable and testable. Avoid repeating if current_user == @resource.user
everywhere — instead, centralize that logic.
✅ Best Practices
1. Always check permissions in the controller, not just the view
Never rely solely on UI logic to hide unauthorized actions. Always use authorize @resource
or can?(:action, resource)
in the controller to enforce access control.
2. Use policies or abilities to centralize permission logic
Rather than writing repeated conditionals in your controller or model, define your access rules in PostPolicy
(Pundit) or Ability
(CanCanCan). This makes your app cleaner and easier to test.
3. Apply scope filters for list actions
Don’t allow users to see data they shouldn’t. Use policy_scope(Post)
or Post.accessible_by(current_ability)
to scope records based on the current user.
4. Use ownership checks (e.g., record.user_id == user.id
)
For user-owned records like posts, tasks, or messages, base access control on whether the current user is the creator/owner of the record.
5. Return appropriate HTTP status codes for API responses
If unauthorized, return 403 Forbidden
for known users and 404 Not Found
when you want to hide the existence of the resource altogether.
6. Add fallback protection in ApplicationController
Rescue from Pundit::NotAuthorizedError
or CanCan::AccessDenied
globally and handle unauthorized access with proper redirection or JSON response.
7. Use feature tests and policy specs
Write unit tests for your policy rules and end-to-end tests to ensure users can’t access other people’s data — even by manually entering URLs or modifying API calls.
8. Hide UI actions based on policy/permission checks
Use helpers like:
<% if policy(@post).edit? %> ... <% end %>
<% if can?(:destroy, post) %> ... <% end %>
This ensures a seamless experience where users only see what they’re allowed to act on.
9. Avoid leaking IDs of unauthorized records
Don’t display or link to resource IDs that users aren’t allowed to access — especially in JSON responses or HTML links.
10. Use includes
to avoid N+1 queries when scoping resources
When scoping data by current user, preload associations to optimize performance:
policy_scope(Post.includes(:comments))
🌍 Real-world Scenario
You’re building a productivity app like Notion or Trello where users can create and manage their own projects and tasks. Each task belongs to a user, and users should only be able to:
- ✅ View their own tasks
- ✅ Update or delete tasks they created
- ❌ Not access tasks created by other users (unless they’re an admin)
🔐 Step 1: Define ownership in the model
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :user
end
🛡 Step 2: Add a policy for task permissions (Pundit)
# app/policies/task_policy.rb
class TaskPolicy < ApplicationPolicy
def show?
user.admin? || record.user_id == user.id
end
def update?
user.admin? || record.user_id == user.id
end
def destroy?
user.admin? || record.user_id == user.id
end
class Scope < Scope
def resolve
user.admin? ? scope.all : scope.where(user_id: user.id)
end
end
end
🔍 Step 3: Authorize access in the controller
def index
@tasks = policy_scope(Task)
end
def show
@task = Task.find(params[:id])
authorize @task
end
🧪 Step 4: Protect unauthorized access
If a user tries to access someone else’s task directly using a URL (e.g. /tasks/99
), the authorize
method will raise a Pundit::NotAuthorizedError
. You can handle this in:
# app/controllers/application_controller.rb
rescue_from Pundit::NotAuthorizedError do
redirect_to root_path, alert: "Access denied."
end
🔎 Result:
- ✅ Regular users can only manage their own tasks
- ✅ Admins can access everything
- ✅ API responses are scoped and filtered by the user
- ✅ The UI dynamically shows only what the user is allowed to see
This type of resource-based access is a must-have in any app where sensitive or user-specific data is involved. It ensures privacy, security, and peace of mind for your users.
Using Service Objects (app/services)
🧠 Detailed Explanation
A Service Object in Rails is a plain Ruby object (PORO) used to encapsulate business logic that doesn’t naturally belong in controllers or models.
It’s a part of the Single Responsibility Principle — helping to keep your code modular, testable, and easy to maintain.
🚫 The Problem Without Service Objects
Suppose you’re signing up a user. If all your logic is inside a controller or model, it might include:
- Creating a new user
- Sending a welcome email
- Creating a profile or welcome notification
- Logging analytics or external service calls
😓 That’s a lot of responsibilities for one place! It becomes hard to test, reuse, and debug.
✅ What Service Objects Solve
Service objects extract all that logic into a single, well-named class — like UserSignupService
— which contains exactly what the name implies.
# app/services/user_signup_service.rb
class UserSignupService
def initialize(params)
@params = params
end
def call
user = User.create(@params)
WelcomeMailer.send_email(user).deliver_later if user.persisted?
user
end
end
✅ Simple. Focused. Testable.
📦 When to Use a Service Object
- When a controller action involves more than one model
- When the logic is reused in multiple places
- When the operation could fail in multiple ways
- When the operation might be backgrounded later
- When it’s hard to unit test logic in the current place
📁 Where to Store It
Conventionally, Rails devs place service objects in the app/services/
directory.
Some teams use namespacing like Services::User::Signup
or User::SignupService
depending on structure and preferences.
✨ Benefits at a Glance
- ✔ Keeps models “skinny”
- ✔ Keeps controllers “skinny”
- ✔ Easier to test and reuse
- ✔ More readable and intuitive business logic
- ✔ Scales well in large apps
✅ Service Objects help organize code that’s messy, repetitive, or in danger of turning into a “God model/controller.” They’re a key part of writing clean, maintainable Rails code.
📦 Step-by-Step Implementation
Service objects help move complex logic out of controllers and models into clean, reusable classes.
🛠 Step 1: Create the app/services
folder (if not already there)
# Run this once
mkdir app/services
🧱 Step 2: Generate a service class
# app/services/user_signup_service.rb
class UserSignupService
def initialize(params)
@params = params
end
def call
user = User.new(@params)
if user.save
WelcomeMailer.send_email(user).deliver_later
end
user
end
end
✅ Focused on one thing: creating the user and sending a welcome email.
💡 Step 3: Use the service object in your controller
# app/controllers/users_controller.rb
def create
@user = UserSignupService.new(user_params).call
if @user.persisted?
redirect_to root_path, notice: "Signed up!"
else
render :new
end
end
🔁 Step 4: Optional — use a class-level .call
method
class UserSignupService
def self.call(params)
new(params).call
end
end
# Now you can call like:
UserSignupService.call(params)
💡 This makes the service feel like a method and improves readability.
🧪 Step 5: Test the service object
# spec/services/user_signup_service_spec.rb
RSpec.describe UserSignupService do
it "creates a user and sends a welcome email" do
expect {
described_class.call({ email: "test@example.com", password: "secret123" })
}.to change(User, :count).by(1)
end
end
✅ Easy to test because it’s decoupled from controllers and views.
📌 Summary
- Create in
app/services
- Use
initialize
+call
pattern - Keep logic focused — one service per business task
- Test in isolation with unit specs
- Chain complex flows using multiple services if needed
💡 Examples
Example 1: Basic user signup service
# app/services/user_signup_service.rb
class UserSignupService
def initialize(params)
@params = params
end
def call
user = User.new(@params)
if user.save
WelcomeMailer.send_email(user).deliver_later
end
user
end
end
# Controller usage
@user = UserSignupService.new(user_params).call
Example 2: Order processing service
# app/services/order_processor_service.rb
class OrderProcessorService
def initialize(order)
@order = order
end
def call
charge_customer
update_inventory
send_confirmation_email
@order.update(status: 'completed')
end
private
def charge_customer
Stripe::Charge.create(...)
end
def update_inventory
@order.line_items.each { |item| item.product.decrement!(:stock, item.quantity) }
end
def send_confirmation_email
OrderMailer.confirmation(@order).deliver_later
end
end
✅ Handles multiple responsibilities — a great candidate for service extraction.
Example 3: Encapsulating an API call
# app/services/weather_fetcher_service.rb
class WeatherFetcherService
def initialize(city)
@city = city
end
def call
HTTParty.get("https://api.weatherapi.com/#{@city}")
end
end
💡 Keeps external API logic outside of models and controllers.
Example 4: Class method shorthand
# app/services/token_refresher_service.rb
class TokenRefresherService
def self.call(user)
new(user).call
end
def initialize(user)
@user = user
end
def call
@user.update(api_token: SecureRandom.hex(32))
end
end
# Usage
TokenRefresherService.call(current_user)
✨ Using .call
is a convention for simpler usage.
Example 5: Service object with error handling
# app/services/payment_service.rb
class PaymentService
def initialize(order)
@order = order
end
def call
raise "Order already paid" if @order.paid?
PaymentGateway.charge(@order)
@order.update!(paid: true)
rescue PaymentGateway::Error => e
Rails.logger.error("Payment failed: #{e.message}")
false
end
end
✅ Gives you full control over logic + error handling.
🔁 Alternatives
- Fat models (anti-pattern)
- Concerns (shared modules, not actions)
- Inline controller logic (messy, hard to reuse/test)
❓ General Questions & Answers
Q1: What is a service object in Rails?
A: A service object is a plain Ruby class used to encapsulate business logic that doesn’t belong in models or controllers. It promotes single responsibility and helps keep your application easier to manage and test.
Q2: When should I use a service object?
A: Use it when an action:
- Touches multiple models
- Calls an external API
- Has side effects (emailing, payment, etc.)
- Doesn’t belong to just one model
Q3: Where do I put service objects?
A: In the app/services
directory. If needed, you can namespace them further (e.g., Services::User::Signup
or User::SignupService
) to keep them organized.
Q4: Is it a Rails convention?
A: While not a built-in Rails convention like models or controllers, service objects are a widely accepted Ruby and Rails best practice in the community for organizing business logic.
Q5: Does each service object need a call
method?
A: Not required, but it’s a community convention that makes them consistent and easy to understand. It allows you to use syntax like MyService.call(args)
.
Q6: Can I return more than just one object?
A: Yes. You can return a struct, a hash, a result object, or use a gem like dry-monads
to standardize success/failure states. For example:
OpenStruct.new(success?: true, user: user)
Q7: Should I make my service object classes reusable?
A: Yes — one of the biggest benefits of service objects is reusability. If you find yourself repeating similar logic in different parts of your app, a service object can clean that up.
Q8: Is this only for backend logic?
A: Mostly yes — service objects are typically used for server-side logic. But if you’re building APIs or background jobs, they also help abstract and reuse logic consistently across those layers.
🛠️ Technical Q&A
Q1: What’s the difference between a Service Object and a Concern?
A: A Concern is a module (used for sharing logic across classes), while a Service Object is a standalone Ruby class designed to perform one specific task. Concerns extend existing classes — service objects don’t.
Q2: Can I pass multiple models into a service?
A: Yes. A service object can accept any number of dependencies in its constructor. Example:
def initialize(user, cart)
@user = user
@cart = cart
end
This is great for workflows like purchases, where multiple models are involved.
Q3: How do I handle errors inside a service object?
A: You can:
- Use
begin/rescue
blocks and log or return fallback responses - Raise custom exceptions (like
OrderProcessingFailed
) - Use
OpenStruct
orDry::Monads::Result
for structured outcomes
Q4: Should I use class methods or instance methods?
A: Use instance methods for actual logic and add a self.call
class method as a shortcut. Example:
def self.call(args)
new(args).call
end
This lets you use service objects like: MyService.call(params)
Q5: Can service objects be chained together?
A: Yes — this is a powerful pattern. You can have one service call another to compose workflows:
def call
token = TokenGeneratorService.call(@user)
NotifierService.call(@user, token)
end
Q6: How should I test service objects?
A: Use unit specs. Focus on:
- ✅ The output
- ✅ Side effects (e.g., emails, DB changes)
- ✅ Error handling logic
it "creates a user" do
result = UserSignupService.call(valid_params)
expect(result).to be_persisted
end
Q7: Can I use ActiveModel in my service objects?
A: Yes! You can include ActiveModel::Model
if you want validations, error handling, or form-like behavior (e.g., in multi-step forms or wizard flows).
Q8: How big is “too big” for a service object?
A: If it starts handling more than one distinct responsibility, break it into smaller service classes or delegate logic to helpers or sub-services. Keep them readable and focused.
✅ Best Practices
1. One service, one responsibility
Each service object should handle exactly one business task (e.g., UserSignupService
, OrderProcessorService
). If it’s doing multiple unrelated things, it’s time to break it down.
2. Name clearly and consistently
Use consistent naming like:
SignupService
Payment::ChargeCustomer
Order::Create
3. Add a .call
class method
This makes service usage elegant and familiar:
UserSignupService.call(params)
Internally, this usually just calls new(...).call
.
4. Avoid ActiveRecord-style complexity inside services
Your service shouldn’t turn into a second model. Don’t overuse callbacks, validations, or internal magic. Keep it as simple Ruby unless you explicitly want to use ActiveModel::Model
.
5. Return predictable outputs
Return values like:
true
/false
- OpenStruct:
OpenStruct.new(success?: true, object: user)
- Dry monads:
Success(user)
/Failure(error)
6. Keep logic testable — inject dependencies when needed
Avoid hardcoding services or models. Inject them through initialization if needed for testability and flexibility:
def initialize(order, payment_gateway: StripeService)
@order = order
@gateway = payment_gateway
end
7. Don’t skip controller authorization just because logic is in a service
Service objects help structure business logic, but you should still enforce things like authorize
or authenticate_user!
in your controller.
8. Keep services framework-agnostic
Avoid Rails-specific dependencies unless necessary (like ActionMailer or ActiveRecord). This keeps the service reusable in other contexts (jobs, scripts, etc.).
9. Log or monitor key service flows
If a service handles something critical (payments, onboarding), consider logging events or tracking metrics. For example:
Rails.logger.info("[Signup] User created: #{user.id}")
10. Use sub-services to keep large services clean
Split big workflows into multiple services and call them inside a “main” orchestrator service. This keeps code clean and easier to trace/debug.
🌍 Real-world Scenario
Imagine you’re building a SaaS invoicing platform like FreshBooks or QuickBooks. When a user clicks “Send Invoice,” several actions must happen:
- ✅ Save the invoice
- ✅ Charge the client via Stripe
- ✅ Generate a PDF receipt
- ✅ Email the receipt to the client
- ✅ Notify the invoice owner via dashboard notification
😵 Without a Service Object
All this logic lives inside the controller or the model, making them large and hard to test. You’d repeat it in your background job or API controller too.
✅ With a Service Object
Create a dedicated service:
# app/services/invoice_sender_service.rb
class InvoiceSenderService
def initialize(invoice, payment_gateway: StripeService)
@invoice = invoice
@payment_gateway = payment_gateway
end
def call
@invoice.save!
@payment_gateway.charge(@invoice)
pdf = InvoicePdfGenerator.call(@invoice)
InvoiceMailer.send_receipt(@invoice, pdf).deliver_later
Notification.create!(user: @invoice.user, message: "Invoice sent.")
true
rescue => e
Rails.logger.error("Invoice failed: #{e.message}")
false
end
end
Now, in your controller or job:
if InvoiceSenderService.call(@invoice)
redirect_to invoices_path, notice: "Invoice sent!"
else
render :new, alert: "Failed to send invoice."
end
📌 Benefits Realized
- ✅ Controller stays clean and readable
- ✅ Service logic is isolated and testable
- ✅ You can reuse the service in jobs, rake tasks, or APIs
- ✅ Easy to log, monitor, or wrap with transactions
In large applications, service objects become the “backbone” of business workflows — reliable, isolated, and easier to test than fat controllers or tangled callbacks.
Organizing Code by Business Logic
🧠 Detailed Explanation
By default, Rails organizes code by type: models/
, controllers/
, views/
, etc. While this structure works well for small apps, it often becomes harder to manage as the app and team grow.
Organizing by business logic means grouping related files by what they do — not just what they are. You group everything related to a feature or domain together (like all invoice-related code).
For example, instead of:
app/models/invoice.rb
app/controllers/invoices_controller.rb
app/services/invoice_mailer_service.rb
You use:
app/domains/invoicing/invoice.rb
app/domains/invoicing/invoices_controller.rb
app/domains/invoicing/mailer_service.rb
🎯 Why This Matters
- ✅ Keeps business features in one place
- ✅ Makes it easier for teams to own specific domains
- ✅ Easier to test, maintain, and onboard new developers
- ✅ Reduces confusion and duplication
🧩 Common Names for This Pattern
- Modular Rails
- Domain-Driven Design (DDD)
- Service-based or feature-based architecture
- Component-based Rails
📦 What to Include in a Domain Folder
- ✔️ Models or POROs
- ✔️ Services
- ✔️ Jobs
- ✔️ Policies (e.g. Pundit)
- ✔️ Mailers or notifiers
- ✔️ Optional: Views, controllers, and specs
🔁 Works Well With
- 🔸 Service Objects
- 🔸 Pundit Policies
- 🔸 ActiveJob & Sidekiq workers
- 🔸 Hotwire/Turbo modular components
- 🔸 Custom folders like
app/features
orapp/components
💡 When to Start Doing This
- 👉 As soon as your app has 3+ business domains
- 👉 When you see controllers bloating or service files scattered
- 👉 When multiple teams or devs are working on different features
✅ If your app includes areas like billing, messaging, and analytics, each can live in its own subfolder with all related logic grouped together.
📦 Step-by-Step Implementation
Here’s how to organize your Rails app by business logic instead of traditional MVC folders.
📁 Step 1: Create a domain-specific folder
Start by creating a directory like app/domains
or app/features
:
mkdir -p app/domains/invoicing
Within it, create files like:
invoice.rb
— Model or domain entitysender_service.rb
— Business logic serviceinvoice_policy.rb
— Pundit policycontroller/invoices_controller.rb
— Controller (optional)
🧠 Step 2: Add folder to autoload paths
Tell Rails to autoload files in app/domains
by adding this to config/application.rb
:
# config/application.rb
config.paths.add 'app/domains', eager_load: true
Or, for Rails < 7:
config.autoload_paths += %W(#{config.root}/app/domains)
🧩 Step 3: Use modules to organize namespace
Inside your domain folder, namespace your classes for clarity:
# app/domains/invoicing/sender_service.rb
module Invoicing
class SenderService
def call
# send invoice
end
end
end
🎯 Step 4: Reference it in controllers, jobs, or tests
# Usage in a controller or background job
Invoicing::SenderService.new.call
You can keep your app/controllers folder clean or route requests directly to domain-layer controllers:
# config/routes.rb
namespace :invoicing do
resources :invoices, only: [:create, :index]
end
🧪 Step 5: Mirror test folder structure
spec/domains/invoicing/sender_service_spec.rb
spec/domains/invoicing/invoice_spec.rb
This keeps tests aligned with your logical structure, making them easier to find and scale.
📌 Summary
- 📁 Create
app/domains
orapp/features
folder - 🧭 Group by domain (e.g.,
billing
,messaging
) not just file type - 🔁 Autoload the new paths
- 💎 Use modules to namespace logic
- ✅ Keep services, policies, jobs, and controllers close to their context
💡 Examples
Example 1: Domain-Based File Structure for Invoicing
app/
domains/
invoicing/
invoice.rb # Model or PORO
creator_service.rb # Business logic
sender_service.rb # Mail logic
invoice_policy.rb # Authorization
invoices_controller.rb # Optional: feature-specific controller
views/
invoices/
show.html.erb
jobs/
send_invoice_job.rb
✅ Keeps all invoice-related logic in one place — easy to navigate and maintain.
Example 2: Code for “Messaging” Feature
app/
domains/
messaging/
message.rb
sender_service.rb
message_policy.rb
notifications/
push_notification.rb
email_notification.rb
controllers/
messages_controller.rb
views/
messages/
index.html.erb
🔁 Business logic and delivery logic are clearly separated inside the messaging domain.
Example 3: Alternative – Feature Folders
Some teams prefer app/features/
instead of app/domains/
.
app/features/payments/
charge.rb
refund.rb
stripe_adapter.rb
charge_controller.rb
views/
charges/
new.html.erb
✅ Still scoped by business concern, just with a different naming convention.
Example 4: RSpec Tests Aligned with Domain Folders
spec/domains/invoicing/invoice_spec.rb
spec/domains/invoicing/creator_service_spec.rb
spec/domains/invoicing/policies/invoice_policy_spec.rb
🧪 Tests live right next to the code they’re testing — making it easier to keep code and tests in sync.
Example 5: Loading from Custom Folder
# config/application.rb
config.paths.add 'app/domains', eager_load: true
✅ Ensures Rails knows where to find and autoload your new folder structure.
🔁 Alternatives
- 💡 Keep traditional MVC layout for small projects
- 💡 Use Rails engines for full domain separation
- 💡 Use
app/features/
orapp/domains/
as custom folders
❓ General Questions & Answers
Q1: What does it mean to organize code by business logic?
A: It means structuring your Rails app by feature or domain (like invoicing
, messaging
, billing
) rather than by file type (like models/
, controllers/
). Each domain contains all relevant files: models, services, policies, jobs, and even views.
Q2: Is this supported by Rails?
A: Yes. While Rails defaults to MVC folders, you can create custom folders like app/domains
and register them in config/application.rb
using:
config.paths.add 'app/domains', eager_load: true
Rails will then autoload and eager load everything inside.
Q3: When should I use this pattern?
A: As soon as your app starts growing beyond a few models or teams. It’s especially helpful when you have multiple business units, APIs, or domains like payments, users, analytics, etc.
Q4: Does this replace MVC structure?
A: No, it complements it. You still use models, controllers, and services — but now they’re grouped by purpose instead of being scattered in separate folders.
Q5: Can I still use Rails generators?
A: Yes, but you’ll likely move files afterward. You can also customize generators or create your own templates if you want to generate directly inside domain folders.
Q6: What are some good folder names?
A: Use names based on business concepts, like:
billing
accounts
notifications
products
auth
Q7: Does this work with engines?
A: Yes. In fact, this structure is a lightweight alternative to using Rails engines. Engines enforce stricter isolation. Domains are more flexible but still provide separation of concerns.
Q8: Do I need to refactor everything?
A: Not at all. You can migrate feature-by-feature or folder-by-folder. Start by grouping new features this way, or refactor older parts when touching them again.
🛠️ Technical Q&A
Q1: How do I make Rails recognize folders like app/domains
?
A: Add this to config/application.rb
so Rails autoloads and eager loads your new structure:
# Rails 6 and later
config.paths.add 'app/domains', eager_load: true
# OR (older versions)
config.autoload_paths += %W(#{config.root}/app/domains)
Q2: Will this work with Zeitwerk (Rails’ autoloader)?
A: Yes, if your folder names and module/class names match. For example:
app/domains/invoicing/invoice.rb
# File: app/domains/invoicing/invoice.rb
module Invoicing
class Invoice
end
end
Zeitwerk will autoload it as Invoicing::Invoice
.
Q3: Can I override default Rails folder behavior?
A: Yes. You can remove unused folders like app/helpers
or app/channels
and replace them with your domain folders. Rails doesn’t force you to keep default folders if you don’t need them.
Q4: How should I structure tests?
A: Mirror your domain structure in spec/domains
or test/domains
:
spec/domains/invoicing/invoice_spec.rb
spec/domains/invoicing/sender_service_spec.rb
This keeps the logic and its tests side-by-side conceptually.
Q5: Can I use Rails generators with this structure?
A: Rails generators still work, but they’ll place files in default folders. You can move the files manually or customize generators if you want automated domain folder output.
Q6: Can I namespace controllers in domains?
A: Yes. You can define controllers like Invoicing::InvoicesController
and add routes using:
namespace :invoicing do
resources :invoices
end
Then Rails will look for app/domains/invoicing/invoices_controller.rb
.
Q7: Can I use hot reload with domain folders?
A: Yes. As long as the folders are autoloaded and follow naming conventions, Rails will hot-reload them like any other class in development mode.
Q8: What if I have nested domains?
A: That’s totally valid. Just mirror the folder and module structure:
app/domains/billing/payments/processor.rb
module Billing
module Payments
class Processor
end
end
end
✅ Best Practices
1. Group by domain, not file type
Instead of placing all models in app/models
and all controllers in app/controllers
, group them by domain (e.g., billing/
, messaging/
).
app/domains/billing/invoice.rb
app/domains/billing/invoices_controller.rb
app/domains/billing/payment_service.rb
2. Use modules to namespace logic
Match module names to folders to ensure clean autoloading with Zeitwerk:
module Billing
class Invoice
end
end
3. Mirror folder structure in specs
Keep tests organized just like your code:
spec/domains/billing/invoice_spec.rb
spec/domains/messaging/sender_service_spec.rb
4. Register custom folders in application.rb
Ensure Rails can find your domain folders:
config.paths.add 'app/domains', eager_load: true
5. Treat each domain like a mini app
Include everything needed for that domain — models, services, jobs, controllers, and maybe views. This creates high cohesion and low coupling.
6. Don’t over-modularize too early
Start small. Migrate feature-by-feature. Don’t refactor the entire app at once. Apply this structure to new domains or during rewrites.
7. Use service objects and policy objects per domain
Keep each domain self-contained by storing its service logic, policies, and jobs within the same folder:
app/domains/billing/
invoice.rb
payment_service.rb
invoice_policy.rb
jobs/process_payment_job.rb
8. Avoid bloating your root namespace
Namespacing prevents class collisions and keeps your domain-specific classes focused:
# ✅ Good
Billing::Invoice
# ❌ Bad
Invoice (shared by multiple domains)
9. Combine this pattern with POROs, not fat models
Put business logic in service objects, interactors, or command objects inside the domain, not in giant ActiveRecord models.
10. Educate your team and document structure
This pattern is powerful but non-standard. Add a README or dev onboarding doc explaining where things live, what belongs where, and how to name folders and modules.
🌍 Real-world Scenario
You’re building a platform like Shopify or Stripe, which has several business domains:
- 💳 Billing
- 📦 Inventory
- 📨 Messaging
- 🧾 Invoicing
- 👥 User Management
If you use the default Rails structure, your app will eventually have hundreds of files in the app/models
and app/controllers
folders — making it hard to maintain and onboard new developers.
✅ Solution: Organize by Business Logic
Instead of scattering files, you create a structure like:
app/
domains/
billing/
invoice.rb
charge_service.rb
payment_gateway_adapter.rb
invoices_controller.rb
views/invoices/show.html.erb
policies/invoice_policy.rb
messaging/
message.rb
sender_service.rb
message_controller.rb
jobs/deliver_message_job.rb
inventory/
product.rb
stock_tracker_service.rb
reports_controller.rb
🔁 Each domain is like a mini app — easy to assign to a dev or team.
📈 Benefits Realized
- ✅ Teams can work on different domains without conflict
- ✅ Easier onboarding for new devs — one folder = one domain
- ✅ Faster debugging and code navigation
- ✅ Fewer merge conflicts and tighter unit tests
- ✅ Cleaner git history and clearer ownership
💡 In Practice
A new dev is assigned to fix a bug in invoice PDF generation
. They don’t need to hunt through 10 folders — they go to:
app/domains/billing/pdf_generator.rb
If they need to add a background job for billing retries:
app/domains/billing/jobs/retry_failed_charge_job.rb
This makes the code more discoverable and maintainable — and the business logic more explicit and scalable.
Extracting Reusable Modules / Helpers
🧠 Detailed Explanation
In a growing Rails application, you often need the same logic in multiple places: formatting dates, calculating prices, sanitizing text, integrating APIs, etc. Instead of duplicating code, you can extract it into reusable modules or helpers.
🔧 What Are Modules and Helpers?
-
Modules: Ruby’s way to group reusable methods and logic. You can
include
them in classes to share instance methods orextend
to add class methods. - Helpers: Rails-specific modules used mostly for view-related logic like formatting, UI helpers, conditional display logic, etc.
-
Concerns: A special type of module Rails uses for reusable model logic. Typically placed in
app/models/concerns/
.
💡 Why Extract to Modules?
- ✅ Avoid code duplication
- ✅ Increase maintainability
- ✅ Improve readability by abstracting low-level logic
- ✅ Make testing easier by isolating responsibilities
📁 Where to Place Modules
app/helpers/
– UI & formatting logic for viewsapp/models/concerns/
– model-specific shared behaviorapp/services/
– cross-service utilities or mixinslib/
– general-purpose modules, integrations, utilities
🔀 Include vs Extend
include
→ Adds module’s methods as instance methods to the target classextend
→ Adds module’s methods as class methods
module ExampleModule
def hello
"hello!"
end
end
class A
include ExampleModule
end
class B
extend ExampleModule
end
A.new.hello # => "hello!"
B.hello # => "hello!"
⚠️ When to Avoid
- ❌ When the module becomes too large — break it down
- ❌ When you use it in only one place — keep it local
- ❌ When it mixes unrelated responsibilities
✅ Use reusable modules and helpers to keep logic DRY, organized, and easily testable across your Rails app.
📦 Step-by-Step Implementation
Follow these steps to extract and use reusable modules and helpers in Rails — the right way.
📁 Step 1: Create a reusable helper or concern
For view-related logic, create a helper:
# app/helpers/formatting_helper.rb
module FormattingHelper
def currency(amount)
"$#{'%.2f' % amount}"
end
end
For reusable model logic or business behavior, create a concern:
# app/models/concerns/trackable.rb
module Trackable
extend ActiveSupport::Concern
included do
before_create :set_tracking_token
end
def set_tracking_token
self.token ||= SecureRandom.uuid
end
end
🧠 Step 2: Include the module where needed
# In a model
class User < ApplicationRecord
include Trackable
end
# In a controller or view
include FormattingHelper
🧪 Step 3: Write specs for your module
# spec/helpers/formatting_helper_spec.rb
RSpec.describe FormattingHelper do
include described_class
it "formats currency" do
expect(currency(10)).to eq("$10.00")
end
end
# spec/models/concerns/trackable_spec.rb
RSpec.describe Trackable do
let(:dummy_class) do
Class.new do
include ActiveModel::Model
include Trackable
attr_accessor :token
end
end
it "sets a token before creation" do
instance = dummy_class.new
instance.set_tracking_token
expect(instance.token).not_to be_nil
end
end
🛠️ Step 4: Use from lib (optional)
For general-purpose Ruby modules (e.g., integrations), place them in lib/
:
# lib/markdown_renderer.rb
module MarkdownRenderer
def self.render(text)
Kramdown::Document.new(text).to_html
end
end
Don’t forget to autoload it in config/application.rb
:
config.autoload_paths += %W(#{config.root}/lib)
📂 Step 5: Keep it organized and named by role
- ✅ Use
app/helpers
for views - ✅ Use
app/models/concerns
for model logic - ✅ Use
lib/
for non-Rails-specific Ruby utilities - ✅ Use modules in
app/services
if used across services
✅ Keep each module focused. Don’t dump unrelated methods into shared files.
💡 Examples
Example 1: A formatting helper for views
# app/helpers/formatting_helper.rb
module FormattingHelper
def format_price(price)
"$#{'%.2f' % price}"
end
end
# app/views/products/show.html.erb
<p>Price: <%= format_price(@product.price) %></p>
✅ View stays clean, formatting logic lives in a helper.
Example 2: Shared discount logic across models
# app/models/concerns/discountable.rb
module Discountable
extend ActiveSupport::Concern
def apply_discount(amount)
amount - (amount * discount_rate)
end
end
# app/models/product.rb
class Product < ApplicationRecord
include Discountable
end
# app/models/subscription.rb
class Subscription < ApplicationRecord
include Discountable
end
✅ Reusable logic across multiple models without duplication.
Example 3: Utility methods as class-level helpers
# lib/string_tools.rb
module StringTools
def shout(text)
text.upcase + "!"
end
end
# app/models/post.rb
class Post < ApplicationRecord
extend StringTools
end
Post.shout("hello") # => "HELLO!"
✅ Adds a class method from a custom lib module.
Example 4: Shared logic between service objects
# app/services/concerns/payment_utils.rb
module PaymentUtils
def format_amount(amount)
sprintf('%.2f', amount)
end
end
# app/services/charge_customer_service.rb
class ChargeCustomerService
include PaymentUtils
def call
format_amount(199.99)
end
end
✅ Shared utility logic across multiple services.
Example 5: Automatically set UUID for models
# app/models/concerns/uuid_generator.rb
module UuidGenerator
extend ActiveSupport::Concern
included do
before_create :generate_uuid
end
def generate_uuid
self.uuid = SecureRandom.uuid
end
end
# app/models/user.rb
class User < ApplicationRecord
include UuidGenerator
end
✅ Automatically injects behavior into models using Rails concern callbacks.
🔁 Alternatives
- ✅ Service objects for actions rather than mixins
- ✅ Decorators or Presenters for view-related logic
- ✅ Concerns only for truly shared behavior, not junk drawers
❓ General Questions & Answers
Q1: What’s the difference between a helper and a module in Rails?
A: All helpers are modules, but not all modules are helpers. Helpers are typically used in views and live in app/helpers
. Regular modules can be used anywhere (models, services, etc.) and are commonly placed in app/models/concerns
or lib/
.
Q2: When should I create a concern instead of a service or model method?
A: Create a concern when the logic:
- Is reusable across multiple models
- Relates to model behavior (e.g., callbacks, validations)
- Is not specific to a single class
Q3: What’s the difference between include
and extend
in modules?
A:
include
→ Adds methods as instance methodsextend
→ Adds methods as class methods
include
in models or service classes when calling methods on instances.
Q4: Where should I put reusable modules?
A: It depends on what they’re used for:
app/helpers
→ View-related logicapp/models/concerns
→ Shared model logiclib/
→ General-purpose utilities or integrationsapp/services/
→ Shared logic across service objects
Q5: How do I test helpers and modules?
A: Use RSpec or Minitest by including the module in a dummy class or directly in the spec. You can write unit tests just like you would for any Ruby class.
Q6: Should I put all logic in helpers?
A: No. Helpers are only for display/view logic (formatting, conditional UI). For business logic, use concerns or service modules instead.
Q7: Can I use module methods in a Rails console?
A: Yes. You can include
or extend
your module into the current context or use its methods if it’s in the load path.
Q8: Do modules affect performance?
A: Not significantly unless overused or misused. Avoid loading unnecessary modules or deeply nested mixins that complicate the inheritance chain.
🛠️ Technical Q&A
Q1: How do I auto-load custom modules from lib/
?
A: In config/application.rb
, add the following:
config.autoload_paths += %W(#{config.root}/lib)
This ensures Rails will load modules from the lib/
folder automatically in development and eager load in production.
Q2: How do concerns work internally in Rails?
A: Concerns are modules that use ActiveSupport::Concern
to allow you to add included do ... end
blocks for callbacks or validations, and automatically extend included classes with additional methods.
module Trackable
extend ActiveSupport::Concern
included do
before_create :track
end
def track
# do something
end
end
Q3: How do I test modules that include callbacks or validations?
A: You can include the concern in a dummy ActiveModel/ActiveRecord-like class inside your spec and test behavior there:
class DummyModel
include ActiveModel::Model
include Trackable
attr_accessor :token
end
Q4: Can I override methods in an included module?
A: Yes, but be cautious. The including class can override any method from a module. If you’re using super
, ensure the method chain is respected.
Q5: Can I pass arguments to modules?
A: Modules don’t take arguments, but you can define instance methods that receive arguments when called from a class that includes the module.
Q6: Can modules include other modules?
A: Yes. Modules can include or extend other modules. This is useful for composing complex behaviors from smaller, focused modules.
module CurrencyFormatter
def format_currency(amount)
"$#{'%.2f' % amount}"
end
end
module ProductUtils
include CurrencyFormatter
end
Q7: Can I use concerns in service objects?
A: Yes! Concerns aren’t limited to models. You can include them in any Ruby class — controllers, service objects, policies, etc. Just place them in a logical folder (like app/services/concerns
).
Q8: How do I organize modules to avoid name collisions?
A: Use proper namespacing:
module Utils
module Text
def self.clean(text)
text.strip.downcase
end
end
end
Then call it as: Utils::Text.clean(" Hello ")
.
✅ Best Practices
1. Keep modules focused on a single responsibility
Don’t use modules as a dumping ground for unrelated methods. For example, avoid a Utils
module with 20 unrelated functions. Instead, use well-named modules like CurrencyFormatter
or TokenGenerator
.
2. Use concerns for shared model logic only
Only use app/models/concerns
for model-specific behavior that is reused across multiple models (e.g., Trackable
, Archivable
). Don’t use concerns for controller logic or helper logic.
3. Use helpers only for view logic
Helpers (in app/helpers
) should not contain business logic. Use them for formatting, conditionals, and UI-related decisions — not payments, authorization, or calculations.
4. Prefer include
for instance methods, extend
for class methods
Use include
when you want the methods to be called on instances (e.g., models, service instances). Use extend
for adding methods directly to a class.
5. Namespace your modules to avoid conflicts
When building large apps, wrap reusable modules under a namespace:
module Utilities
module CurrencyFormatter
def self.format(amount)
"$#{'%.2f' % amount}"
end
end
end
6. Place general-purpose modules in lib/
If a module isn’t tied to a specific Rails component (e.g., model or view), place it in lib/
and autoload it. This keeps app/
organized and semantic.
7. Keep module methods pure and stateless
Modules should generally not maintain state. They should act like utilities: take input, return output, and not rely on instance variables unless needed in Rails concerns.
8. Test modules in isolation
Don’t rely on downstream classes (e.g., models or controllers) for testing your modules. Use dummy classes or direct module tests to ensure they’re reusable and predictable.
9. Avoid circular dependencies
Be cautious when modules include each other. Deeply nested module relationships can lead to confusing errors or autoloading issues.
10. Document what your modules are for
Use comments or YARD documentation to explain what each module does and how to use it — especially if it’s meant to be reused across the app.
🌍 Real-world Scenario
Imagine you’re building a SaaS product like Basecamp or Trello, and multiple features require generating unique tokens, formatting currency, and applying role-based access checks.
🧩 Problem
You have several models (e.g., Project
, UserInvite
, APIKey
) that all need to generate unique tokens.
At first, each class duplicates the same logic:
# app/models/project.rb
before_create { self.token = SecureRandom.hex(10) }
# app/models/api_key.rb
before_create { self.token = SecureRandom.hex(10) }
This logic is duplicated in multiple places, hard to change, and harder to test consistently.
✅ Solution: Extract to a reusable concern
You move the shared behavior into a module:
# app/models/concerns/tokenizable.rb
module Tokenizable
extend ActiveSupport::Concern
included do
before_create :generate_token
end
def generate_token
self.token ||= SecureRandom.hex(10)
end
end
And then use it in multiple models:
# app/models/project.rb
class Project < ApplicationRecord
include Tokenizable
end
# app/models/api_key.rb
class APIKey < ApplicationRecord
include Tokenizable
end
Now, the logic is clean, testable, and reusable.
🧪 Bonus: Test the module in isolation
RSpec.describe Tokenizable do
it "generates a token before create" do
model = Class.new do
include ActiveModel::Model
include Tokenizable
attr_accessor :token
def save; generate_token; end
end.new
model.save
expect(model.token).not_to be_nil
end
end
📈 Benefits Realized
- ✅ DRY logic shared across all token-generating models
- ✅ Easy to change behavior in one place
- ✅ Easy to write unit tests and integration tests
- ✅ Code becomes more readable and intention-revealing
In real teams, helpers and modules like this save hours of debugging, reduce duplicated bugs, and help new devs onboard faster by showing “how we do things” consistently.
Modularize with Concerns
🧠 Detailed Explanation
Rails concerns are a specialized way of extracting reusable logic using Ruby modules. They are ideal for sharing behavior between multiple models or controllers in a clean, structured, and Rails-native way.
💡 Why use concerns?
- ✅ To avoid code duplication across models or controllers
- ✅ To keep classes focused on their primary responsibility
- ✅ To reuse callbacks, scopes, and shared methods easily
Without concerns, developers often copy-paste the same methods or callbacks into multiple classes, which increases bugs and inconsistency. Concerns provide a centralized place to store this logic.
📦 What makes a concern special?
- Rails concerns are just Ruby
modules
- They use
ActiveSupport::Concern
which:- Allows an
included do
block to define class-level behavior (like scopes or callbacks) - Ensures inclusion order is handled properly (dependency-safe)
- Allows an
🔀 Where are concerns typically used?
app/models/concerns/
→ for shared model behaviorapp/controllers/concerns/
→ for shared filters or helper methods in controllers- 💡 You can also organize them in custom folders like
app/services/concerns/
🛠 How are concerns loaded?
Rails autoloads concerns automatically. You don’t need to manually require them if they live in:
app/models/concerns
app/controllers/concerns
These folders are part of Rails’ eager load and autoload paths.
✅ When should I use a concern?
- When multiple classes (models/controllers) share the same logic
- When you need to reuse callbacks (e.g.,
before_create
) - When you want to keep classes slim and focused
⚠️ When not to use concerns
- ❌ Don’t use concerns just to move logic — only extract reusable, shared behavior
- ❌ Avoid creating massive concerns with mixed responsibilities
- ❌ Don’t overuse them in place of proper design patterns like service objects
🧠 Summary
Concerns are a great way to share behavior across classes, but like any tool, they must be used with care. When used well, they promote DRYness, consistency, and clarity.
📦 Step-by-Step Implementation
Here’s how to modularize shared behavior using Rails concerns with proper structure, loading, and usage.
📁 Step 1: Create a concerns directory
Rails includes app/models/concerns
and app/controllers/concerns
by default (autoloaded), but create it if missing:
mkdir -p app/models/concerns
mkdir -p app/controllers/concerns
🧠 Step 2: Define your concern
Use ActiveSupport::Concern
to enable clean included
blocks and extendable behavior:
# app/models/concerns/archivable.rb
module Archivable
extend ActiveSupport::Concern
included do
scope :active, -> { where(archived: false) }
before_create :set_default_archive_status
end
def archive!
update(archived: true)
end
private
def set_default_archive_status
self.archived = false if self.archived.nil?
end
end
🧩 Step 3: Include the concern in your model
# app/models/project.rb
class Project < ApplicationRecord
include Archivable
end
🔁 Step 4: Reuse in other models
You can include the same concern in any other model that shares the behavior:
# app/models/task.rb
class Task < ApplicationRecord
include Archivable
end
🧪 Step 5: Test the concern in isolation
Create a dummy class or use an actual model to test your concern behavior:
RSpec.describe Archivable do
before do
@klass = Class.new do
include ActiveModel::Model
include Archivable
attr_accessor :archived
def update(attrs)
self.archived = attrs[:archived]
end
end
end
it "sets archived to true" do
obj = @klass.new
obj.archive!
expect(obj.archived).to be true
end
end
📌 Step 6: (Optional) Use in controllers too
You can use concerns to share filters or utility methods across controllers:
# app/controllers/concerns/authenticatable.rb
module Authenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_user!
end
end
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
include Authenticatable
end
📦 Final Tip
If your concern grows too big, split it into smaller concerns or move to a service object instead. Keep concerns tight and focused.
💡 Examples
Example 1: Archivable Concern
This concern adds a scope and instance method to archive/unarchive models.
# app/models/concerns/archivable.rb
module Archivable
extend ActiveSupport::Concern
included do
scope :active, -> { where(archived: false) }
end
def archive!
update(archived: true)
end
def unarchive!
update(archived: false)
end
end
# app/models/task.rb
class Task < ApplicationRecord
include Archivable
end
# Usage
Task.active
task.archive!
Example 2: Taggable Concern with Associations
Use a concern to add tag functionality to any model.
# app/models/concerns/taggable.rb
module Taggable
extend ActiveSupport::Concern
included do
has_many :taggings, as: :taggable
has_many :tags, through: :taggings
end
def add_tag(name)
tags.create(name: name)
end
end
# app/models/article.rb
class Article < ApplicationRecord
include Taggable
end
Example 3: Authenticatable Concern for Controllers
Shared controller logic using a concern.
# app/controllers/concerns/authenticatable.rb
module Authenticatable
extend ActiveSupport::Concern
included do
before_action :authenticate_user!
end
def current_user
@current_user ||= User.find_by(token: request.headers["Authorization"])
end
end
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
include Authenticatable
end
Example 4: Soft Deletion Concern with Callbacks
# app/models/concerns/soft_deletable.rb
module SoftDeletable
extend ActiveSupport::Concern
included do
default_scope { where(deleted_at: nil) }
end
def soft_delete
update(deleted_at: Time.current)
end
def deleted?
deleted_at.present?
end
end
# app/models/user.rb
class User < ApplicationRecord
include SoftDeletable
end
# Usage
user.soft_delete
User.all # Excludes soft-deleted users
Example 5: Shared Validations in a Concern
# app/models/concerns/email_validatable.rb
module EmailValidatable
extend ActiveSupport::Concern
included do
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
end
end
# app/models/customer.rb
class Customer < ApplicationRecord
include EmailValidatable
end
# app/models/lead.rb
class Lead < ApplicationRecord
include EmailValidatable
end
🔁 Alternatives
- ✅ Use service objects for business workflows
- ✅ Use decorators or presenters for view logic
- ✅ Extract into modules in
lib/
if it’s not Rails-specific
❓ General Questions & Answers
Q1: What is a concern in Rails?
A: A concern is a Ruby module that uses ActiveSupport::Concern
to share logic between models, controllers, or services. It’s used to extract reusable code like validations, scopes, and callbacks, keeping your classes clean and DRY.
Q2: When should I use a concern?
A: Use a concern when you have logic that is repeated across multiple classes (e.g., soft deletion, tagging, archiving, or shared validations). Concerns help you avoid duplication and increase consistency.
Q3: What’s the difference between a concern and a regular module?
A: Concerns use ActiveSupport::Concern
, which allows you to cleanly separate included blocks (for callbacks and scopes) from instance methods. They’re easier to manage than plain Ruby modules, especially with Rails autoloading and load order.
Q4: Where do concerns go in a Rails project?
A: Rails automatically looks for concerns in:
app/models/concerns
→ for model-related logicapp/controllers/concerns
→ for controller-related logic
app/services/concerns
if needed.
Q5: Can I include multiple concerns in a class?
A: Yes, you can include as many concerns as needed. Just be careful not to overload a class with too many behaviors — that’s a sign it may need to be refactored.
Q6: How do I test concerns?
A: You can test concerns using:
- A dummy class with
include ConcernName
- Real models that include the concern
- RSpec shared examples (if used in multiple specs)
Q7: Should I use concerns for business logic?
A: No. Concerns are best for structural logic (callbacks, validations, scopes). For business workflows (e.g., placing orders, syncing data), use service objects instead.
Q8: Can I use concerns in controllers?
A: Yes! You can create controller concerns in app/controllers/concerns
and include shared before actions, authorization logic, or utility methods across multiple controllers.
🛠️ Technical Q&A
Q1: Why use ActiveSupport::Concern
instead of a plain module?
A: ActiveSupport::Concern
simplifies inclusion by:
- Allowing
included do
blocks for callbacks, scopes, etc. - Automatically extending the host class with class methods via
ClassMethods
module (if defined) - Preventing load-order issues when modules depend on other modules
Q2: How do I include class methods in a concern?
A: Define them inside a ClassMethods
submodule:
module Sluggable
extend ActiveSupport::Concern
module ClassMethods
def find_by_slug(slug)
find_by(slug: slug)
end
end
end
Q3: Can I define constants or private methods in concerns?
A: Yes. You can define constants, private methods, or class-level config. Just be careful not to clutter your concerns with logic unrelated to the shared behavior.
Q4: Can concerns call other concerns?
A: Yes. You can include one concern inside another, but be cautious of circular dependencies and tightly coupled logic. Always group related behavior.
Q5: Are concerns autoloaded in development?
A: Yes. Rails autoloads all files in app/models/concerns
and app/controllers/concerns
. You don’t need to use require
manually unless you’re using a custom folder outside those paths.
Q6: Can I use concerns in non-Rails Ruby classes?
A: Yes. Concerns are just modules. You can include them in plain Ruby classes or service objects. However, ActiveSupport::Concern
is specific to Rails and assumes Rails conventions like callbacks.
Q7: How should I organize concerns in large applications?
A: Use subfolders under concerns/
to organize by domain (e.g., concerns/billing/trackable.rb
) and namespace them accordingly:
module Billing
module Trackable
extend ActiveSupport::Concern
...
end
end
Q8: Can I override concern methods in the including class?
A: Yes. If you define a method in the class with the same name as one in the concern, the class method takes precedence. Just remember to use super
if you want to preserve concern behavior.
✅ Best Practices
1. Use concerns only when logic is reused across multiple classes
Don’t use concerns just to shrink a big model. Concerns are for shared behavior — if it’s only used once, it likely belongs in a service or directly in the model.
2. Keep each concern focused
Name concerns after behavior (Archivable
, Taggable
, SoftDeletable
), not implementation (WithCallbacks
❌).
Stick to one responsibility per concern.
3. Use ActiveSupport::Concern
for clean inclusion
This gives you access to included do
blocks for callbacks and scopes, and handles load order when mixing concerns with other modules.
4. Avoid concerns that depend heavily on external state
Concerns should be reusable and testable in isolation. Don’t tie them too tightly to application-specific logic or obscure instance variables.
5. Organize large sets of concerns by domain
Use folders like app/models/concerns/billing/trackable.rb
and properly namespace them. This improves code discoverability and avoids naming conflicts.
6. Don’t turn concerns into dumping grounds
If your concern is doing validations, callbacks, class methods, and helper methods — it’s probably doing too much. Split it up.
7. Prefer declarative names and public APIs
Keep method names in your concern descriptive and intention-revealing. Don’t leak low-level logic into the concern’s public interface unless necessary.
8. Document what the concern does
Add a short comment at the top of each concern to explain what it does and where it’s expected to be used.
9. Write isolated unit tests for each concern
Use a dummy class to test a concern’s behavior outside of real models or controllers. This ensures your logic is reliable and independent.
10. Don’t overuse concerns in controllers
For controller logic, prefer using ApplicationController methods or service objects. Use controller concerns only for reusable filters or response formatters.
🌍 Real-world Scenario
Imagine you’re building a SaaS platform like Asana or Notion where multiple resources — such as Project
, Task
, and Note
— need features like:
- ✅ Soft deletion
- ✅ Tagging
- ✅ Archiving
- ✅ Access control
If you implement these features directly in every model, the codebase quickly becomes repetitive and hard to maintain.
📦 The Problem: Repeating logic everywhere
# Before (in multiple models)
before_create { self.archived = false }
def archive!
update(archived: true)
end
def archived?
self.archived
end
You now need to apply the same functionality to 5+
models and keep them consistent.
✅ The Solution: Use a concern
# app/models/concerns/archivable.rb
module Archivable
extend ActiveSupport::Concern
included do
before_create :set_default_archived
scope :active, -> { where(archived: false) }
end
def archive!
update(archived: true)
end
def archived?
archived
end
private
def set_default_archived
self.archived = false if archived.nil?
end
end
# app/models/project.rb
class Project < ApplicationRecord
include Archivable
end
# app/models/task.rb
class Task < ApplicationRecord
include Archivable
end
🧪 Testing is now easier
You can test Archivable
independently with a dummy class or shared specs. You no longer need to copy/paste test cases across models.
📈 Benefits Realized
- ✅ Consistency across multiple models
- ✅ Slimmer model classes
- ✅ Isolated, reusable, and testable logic
- ✅ Easy to update or refactor behavior in one place
This concern could now also be reused in an admin interface, exports, or versioned APIs — without any change to your models.
Split Business Logic from Controllers
🧠 Detailed Explanation
In Rails, controllers are meant to handle HTTP responsibilities — routing requests, validating params, invoking the right logic, and formatting responses. Business logic (e.g., charging a payment, updating stock, sending emails, applying discounts) should live outside the controller.
📦 Why move logic out of controllers?
- ✅ Makes code cleaner, more readable, and easier to test
- ✅ Promotes Single Responsibility Principle (SRP)
- ✅ Encourages reusability (can call services from jobs, APIs, CLI)
- ✅ Speeds up debugging and onboarding for new devs
Without separation, your controller becomes a “god object” — handling multiple unrelated concerns, leading to brittle, slow tests and duplication.
🔧 What is business logic?
- Calculations (e.g., totals, tax, commissions)
- Third-party integrations (e.g., Stripe, Twilio)
- Data workflows (e.g., update status, trigger events)
- Processes (e.g., onboarding, refunding, reporting)
If the logic is not directly related to rendering or routing the HTTP request, it probably belongs in a service object.
🧰 What patterns can I use?
- Service objects – Encapsulate one job (e.g.,
OrderCreator
) - Form objects – Combine multiple models or handle validations
- Interactors – Chain actions with rollbacks
- Jobs – Handle background logic (e.g., sending emails)
🧩 Where should service files go?
Create an app/services
directory (if not already present) and place your business classes there:
app/services/
├── order_creator.rb
├── refund_processor.rb
└── user_onboarder.rb
🎯 What makes a good service object?
- ✅ Does one thing well
- ✅ Exposes a single method (usually
#call
) - ✅ Returns structured output (e.g., success/failure)
- ✅ Avoids controller or view-specific code
🚫 Anti-pattern: fat controller
A controller with 50+ lines handling multiple responsibilities is hard to:
- Read
- Maintain
- Test
- Debug
✅ Summary
Keep your controllers clean and lean. Move complex operations to service objects, use them across your app, and write isolated tests for those services. This pattern scales well in teams, improves testability, and leads to maintainable code.
📦 Step-by-Step Implementation
Here’s how to split business logic from Rails controllers using service objects — a simple and scalable approach.
🛠 Step 1: Identify logic to extract
Open your controller and look for actions with complex logic:
- Multiple DB updates
- Conditional flows
- API calls or integrations
- Lengthy method chains
# app/controllers/orders_controller.rb
def create
@order = Order.new(order_params)
@order.calculate_tax
PaymentGateway.charge(@order.total)
InvoiceMailer.send_invoice(@order).deliver_later
render json: @order
end
📁 Step 2: Create a service class
Create a plain Ruby class in app/services
. Use the #call
method as an entry point.
# app/services/order_creator.rb
class OrderCreator
def initialize(order_params)
@order = Order.new(order_params)
end
def call
@order.calculate_tax
PaymentGateway.charge(@order.total)
InvoiceMailer.send_invoice(@order).deliver_later
@order.save
OpenStruct.new(success?: true, order: @order)
rescue => e
OpenStruct.new(success?: false, error: e.message)
end
end
🎯 Step 3: Use the service from your controller
Your controller now delegates to the service and simply handles the result.
# app/controllers/orders_controller.rb
def create
result = OrderCreator.new(order_params).call
if result.success?
render json: result.order, status: :created
else
render json: { error: result.error }, status: :unprocessable_entity
end
end
🧪 Step 4: Test the service in isolation
Services can be tested independently without controller or HTTP logic:
# spec/services/order_creator_spec.rb
describe OrderCreator do
it "creates and charges an order" do
params = { total: 100, tax_rate: 0.1 }
result = described_class.new(params).call
expect(result.success?).to be true
expect(result.order).to be_persisted
end
end
📦 Step 5: Reuse your service
Now the same service can be used from other places — like background jobs, admin dashboards, or APIs — without duplicating logic.
# app/jobs/auto_order_job.rb
OrderCreator.new(order_params).call
🎉 Done!
You now have a clean, testable, and reusable separation of responsibilities between your HTTP controller and your application logic.
💡 Examples
Example 1: Extracting user registration logic
# app/controllers/registrations_controller.rb
def create
result = UserRegistrationService.new(user_params).call
if result.success?
render json: { message: "User created" }, status: :created
else
render json: { errors: result.errors }, status: :unprocessable_entity
end
end
# app/services/user_registration_service.rb
class UserRegistrationService
def initialize(params)
@params = params
end
def call
user = User.new(@params)
if user.save
WelcomeMailer.send_welcome(user).deliver_later
OpenStruct.new(success?: true, user: user)
else
OpenStruct.new(success?: false, errors: user.errors.full_messages)
end
end
end
Example 2: Processing a refund
# app/controllers/refunds_controller.rb
def create
result = RefundProcessor.new(params[:order_id]).call
if result.success?
render json: { message: "Refunded" }
else
render json: { error: result.error }, status: 422
end
end
# app/services/refund_processor.rb
class RefundProcessor
def initialize(order_id)
@order = Order.find(order_id)
end
def call
return error("Already refunded") if @order.refunded?
PaymentGateway.refund(@order.payment_id)
@order.update(refunded: true)
OpenStruct.new(success?: true)
rescue => e
error(e.message)
end
private
def error(message)
OpenStruct.new(success?: false, error: message)
end
end
Example 3: Multi-step logic (Invite + Notification)
# app/controllers/invitations_controller.rb
def create
result = SendTeamInvite.new(params[:email], current_user.team).call
if result.success?
render json: { message: "Invite sent" }
else
render json: { error: result.error }, status: 422
end
end
# app/services/send_team_invite.rb
class SendTeamInvite
def initialize(email, team)
@user = User.find_by(email: email)
@team = team
end
def call
return error("User not found") unless @user
Invitation.create!(user: @user, team: @team)
NotificationService.new(@user).notify("You've been invited!")
OpenStruct.new(success?: true)
rescue => e
error(e.message)
end
private
def error(msg)
OpenStruct.new(success?: false, error: msg)
end
end
🔁 Alternatives
- ✅ Use service objects (e.g., in
app/services
) - ✅ Use form objects (for validation + submission)
- ✅ Use interactors (e.g.,
Interactor
gem) for orchestrating multiple actions
❓ General Questions & Answers
Q1: Why should I avoid putting business logic in controllers?
A: Controllers are meant to be lightweight entry points for web requests. Putting business logic in them makes them bloated, hard to test, and harder to reuse. Extracting logic makes your app cleaner, modular, and easier to scale.
Q2: What kind of logic should go in a controller?
A: Only HTTP-related logic:
- Permitting/validating parameters
- Calling service objects or models
- Handling authentication/authorization
- Rendering JSON or HTML responses
Q3: Where should I put extracted logic?
A: In the app/services
folder. You can organize it further by feature (e.g., app/services/orders/order_creator.rb
) to keep it clean.
Q4: What is a service object?
A: A service object is a plain Ruby class that encapsulates a single unit of business logic — like creating an order, processing a refund, or applying a discount. It usually exposes a #call
method.
Q5: How is this different from putting logic in models?
A: Models are great for data-related behavior (e.g., validations, scopes), but not ideal for complex workflows, external APIs, or transactions. Services help keep models clean and behavior-focused.
Q6: Do I need to use a gem to use service objects?
A: No. Service objects are just plain Ruby classes. You can optionally use gems like interactor
or dry-transaction
for more structure, but they’re not required.
Q7: What’s a good naming convention for service objects?
A: Use descriptive action-oriented names like:
CreateOrder
SendWelcomeEmail
GenerateInvoicePdf
Q8: Can services return structured responses?
A: Yes! Return OpenStruct
, a Hash, or a custom Result object with keys like success?
, error
, and data
to keep response handling clean in the controller.
🛠️ Technical Q&A
Q1: What should a service object return?
A: A service should return a structured result. Use OpenStruct
, a Hash, or a custom Result
object. This allows the controller to easily check success?
, access data, or handle errors.
OpenStruct.new(success?: true, user: @user)
OpenStruct.new(success?: false, error: "Invalid token")
Q2: Where do I define app/services/
if it doesn’t exist?
A: Just create it! Rails autoloads files from the app/services/
directory. You don’t need extra configuration in most cases.
Q3: Should a service object handle errors internally?
A: Yes. Wrap your logic in a begin/rescue
block inside the service. Return a clean failure object instead of letting errors bubble up to the controller.
def call
PaymentGateway.charge(card)
OpenStruct.new(success?: true)
rescue => e
OpenStruct.new(success?: false, error: e.message)
end
Q4: How do I test service objects?
A: Write standard unit tests for service classes. Pass test data, run #call
, and assert on the result. You don’t need to boot Rails controllers or routes.
describe OrderProcessor do
it "marks order as paid" do
order = create(:order)
result = described_class.new(order).call
expect(result.success?).to be true
expect(order.reload.paid).to be true
end
end
Q5: Can a service object depend on other services?
A: Yes. But don’t overload it. Use composition over complexity. Example: a CreateOrder
service can internally call ApplyCoupon
and SendInvoice
.
Q6: Is there a naming convention for service objects?
A: Use verb-noun pairs:
CreateUser
ProcessRefund
SendNotification
#call
method convention.
Q7: Can service objects work with background jobs?
A: Absolutely. A background job (e.g., Sidekiq or ActiveJob) can instantiate a service and call it. This lets you reuse logic across your app (UI, API, CLI, jobs).
Q8: When should I move logic from a service into a model?
A: If the logic is tightly bound to a model’s behavior (e.g., validation, scopes, simple status changes), it’s okay to keep it in the model. If it involves multiple models, external APIs, or workflows — use a service.
✅ Best Practices
1. Keep controller actions short and focused
Your controller actions should ideally be ≤ 5 lines. Use them for request handling, parameter permitting, calling services, and returning responses.
2. Use service objects for workflows and external dependencies
When an action involves multiple steps (like sending emails, applying a discount, or making an API call), move it into a dedicated service.
3. Use a consistent interface (e.g., #call
)
Name your entry method #call
so all services work similarly. It promotes consistency and allows chaining with Ruby’s .call
convention.
4. Return structured result objects
Services should return objects (like OpenStruct
) with keys like success?
, error
, and optional data. This keeps controller logic clean.
5. Group related services under namespaces
For large domains, organize services like:
app/services/payments/process_payment.rb
app/services/orders/create_order.rb
6. Avoid coupling services too tightly
Services can call other services, but they shouldn’t become tangled. Keep each class focused on one job, and pass results cleanly between them.
7. Test services in isolation
Unit test service objects directly, without routing or controller layers. Mock dependencies if needed (e.g., external APIs, file uploads).
8. Handle all exceptions internally
Use begin...rescue
blocks inside the service and return a meaningful error. This avoids controller crashes and keeps response flow consistent.
9. Document the input/output of your services
At the top of each service, comment on:
- Expected parameters
- Return structure
- Side effects (e.g., emails, API calls)
10. Name services after actions
Follow the verb-noun convention: CreateUser
, ProcessRefund
, SendVerificationEmail
. Avoid vague names like Utils
or Manager
.
🌍 Real-world Scenario
You’re building a subscription-based SaaS product like Notion or Figma. When a user upgrades their account, the app must:
- Validate their plan
- Charge their payment method
- Generate an invoice
- Send a confirmation email
🚫 Initial Approach (fat controller)
# app/controllers/subscriptions_controller.rb
def upgrade
plan = Plan.find(params[:plan_id])
if current_user.plan.upgradable_to?(plan)
charge = Stripe::Charge.create(...)
invoice = Invoice.create(user: current_user, charge_id: charge.id)
UserMailer.confirm_upgrade(current_user, plan).deliver_later
current_user.update(plan: plan)
render json: { message: "Upgraded" }
else
render json: { error: "Invalid plan" }, status: 422
end
end
✅ It works. 😬 But the controller mixes:
- Business rules (plan validation)
- External APIs (Stripe)
- Side effects (email sending)
- Database operations
✅ Refactored with Service Object
# app/controllers/subscriptions_controller.rb
def upgrade
result = UpgradeSubscription.new(current_user, params[:plan_id]).call
if result.success?
render json: { message: "Upgraded" }
else
render json: { error: result.error }, status: 422
end
end
# app/services/upgrade_subscription.rb
class UpgradeSubscription
def initialize(user, plan_id)
@user = user
@plan = Plan.find(plan_id)
end
def call
return fail!("Invalid upgrade") unless @user.plan.upgradable_to?(@plan)
charge = Stripe::Charge.create(...) # mockable in tests
Invoice.create(user: @user, charge_id: charge.id)
@user.update(plan: @plan)
UserMailer.confirm_upgrade(@user, @plan).deliver_later
OpenStruct.new(success?: true)
rescue => e
fail!(e.message)
end
private
def fail!(msg)
OpenStruct.new(success?: false, error: msg)
end
end
📈 Benefits Realized
- ✅ Smaller controller (easier to read, test, and debug)
- ✅ Reusable service (can call from CLI, Sidekiq jobs, or admin dashboard)
- ✅ Testable in isolation (no need to hit HTTP routes)
- ✅ Cleaner error handling (no rescue blocks in controller)
This refactor made it easier to add new features (like sending receipts), re-use subscription logic in different parts of the app, and onboard new developers.
Group APIs by Version
🧠 Detailed Explanation
Versioning your APIs is critical when your application serves external clients (like mobile apps, frontend UIs, or third-party integrations). It ensures that updates or changes to your backend don’t break existing consumers of your API.
📦 Why group APIs by version?
- ✅ Allows multiple versions to coexist (e.g.,
/api/v1
and/api/v2
) - ✅ Enables backward compatibility for older mobile apps or clients
- ✅ Makes it easier to refactor or redesign new APIs without breaking others
- ✅ Helps in graceful deprecation of old endpoints
Instead of modifying an existing endpoint in place and potentially breaking live apps, you create a new versioned endpoint (e.g., /api/v2/users
) that offers the updated behavior.
🗂 How does URI-based versioning work?
You define separate namespaces for each version in your Rails routes file:
namespace :api do
namespace :v1 do
resources :users
end
namespace :v2 do
resources :users
end
end
Each version has its own controllers, like:
app/controllers/api/v1/users_controller.rb
app/controllers/api/v2/users_controller.rb
🔀 When should I create a new version?
- 📌 When breaking changes are needed (e.g., removing fields, changing formats)
- 📌 When you introduce major new features or workflows
- 📌 When you need to support legacy clients differently
Minor or backward-compatible updates (like adding a new optional field) may not require a version bump.
💡 Types of versioning in APIs
- URI-based: Most common and visible (
/api/v1/users
) ✅ - Header-based: Uses
Accept: application/vnd.api.v2+json
(harder to test/debug) - Query param-based: (
?version=2
) — not recommended for RESTful APIs
📁 Folder Structure in Rails
Organize versioned controllers like:
app/controllers/
├── api/
│ ├── v1/
│ │ └── users_controller.rb
│ └── v2/
│ └── users_controller.rb
This structure keeps each version isolated while allowing you to reuse models, services, and serializers across versions.
✅ Summary
Grouping APIs by version allows you to build stable, flexible, and future-proof APIs. It empowers teams to evolve features without fear of breaking old clients, and it signals to developers and consumers that you’re serious about compatibility.
📦 Step-by-Step Implementation
Here’s how to group and version your Rails API using /api/v1/
, /api/v2/
routing structure.
📁 Step 1: Create namespaced directories for your API controllers
mkdir -p app/controllers/api/v1
mkdir -p app/controllers/api/v2
📄 Step 2: Define versioned controllers
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
def index
render json: User.all
end
end
end
end
# app/controllers/api/v2/users_controller.rb
module Api
module V2
class UsersController < ApplicationController
def index
render json: User.includes(:profile).as_json(include: :profile)
end
end
end
end
🔀 Step 3: Set up routes
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:index, :show]
end
namespace :v2 do
resources :users, only: [:index, :show]
end
end
end
🔍 Step 4: Keep shared logic outside versioned controllers
Move repeated logic to service objects or modules:
# app/services/user_fetcher.rb
class UserFetcher
def self.all_with_profile
User.includes(:profile)
end
end
# app/controllers/api/v2/users_controller.rb
def index
render json: UserFetcher.all_with_profile
end
🧪 Step 5: Add tests per version
# spec/requests/api/v1/users_spec.rb
describe "GET /api/v1/users" do
it "returns basic users" do
get "/api/v1/users"
expect(response).to have_http_status(:ok)
end
end
# spec/requests/api/v2/users_spec.rb
describe "GET /api/v2/users" do
it "returns users with profile" do
get "/api/v2/users"
expect(json["users"].first["profile"]).to be_present
end
end
📚 Step 6: Update documentation (optional but recommended)
If you use Swagger, RSwag, Postman, or API Blueprint, make sure to document version-specific behavior clearly.
🎉 Done!
You now have a cleanly versioned Rails API using URI-based paths like /api/v1/users
and /api/v2/users
. This allows you to ship changes without breaking backward compatibility.
💡 Examples
Example 1: Basic route setup with versioning
Define versioned routes using namespaces:
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:index, :show]
end
namespace :v2 do
resources :users, only: [:index, :show]
end
end
end
Example 2: Separate controllers per version
# app/controllers/api/v1/users_controller.rb
module Api
module V1
class UsersController < ApplicationController
def index
render json: User.select(:id, :email)
end
end
end
end
# app/controllers/api/v2/users_controller.rb
module Api
module V2
class UsersController < ApplicationController
def index
render json: User.includes(:profile).as_json(include: :profile)
end
end
end
end
✅ In v1, the response returns only basic user info. ✅ In v2, it includes nested profile data.
Example 3: Sharing services across versions
# app/services/user_query.rb
class UserQuery
def self.with_profile
User.includes(:profile)
end
end
# app/controllers/api/v2/users_controller.rb
def index
users = UserQuery.with_profile
render json: users.as_json(include: :profile)
end
✅ Use shared service objects to avoid repeating logic across versions. ✅ Only controller response behavior differs per version.
Example 4: Keeping serializer logic per version
# app/serializers/api/v1/user_serializer.rb
class Api::V1::UserSerializer < ActiveModel::Serializer
attributes :id, :email
end
# app/serializers/api/v2/user_serializer.rb
class Api::V2::UserSerializer < ActiveModel::Serializer
attributes :id, :email, :full_name
end
✅ API v2 introduces additional fields like full_name
.
🎯 Both versions use their own serializer, keeping versions clean and isolated.
Example 5: Version-specific tests
# spec/requests/api/v1/users_spec.rb
describe "GET /api/v1/users" do
it "returns users without profile" do
get "/api/v1/users"
expect(response.parsed_body.first).not_to include("profile")
end
end
# spec/requests/api/v2/users_spec.rb
describe "GET /api/v2/users" do
it "returns users with profile data" do
get "/api/v2/users"
expect(response.parsed_body.first["profile"]).to be_present
end
end
✅ This ensures each version behaves as expected and can evolve independently.
🔁 Alternatives
- ✅ Header-based versioning:
Accept: application/vnd.myapp.v1+json
- ✅ Parameter-based versioning (less common):
?version=1
❓ General Questions & Answers
Q1: Why should I version my API?
A: To prevent breaking changes for existing users. Versioning allows you to safely improve or redesign APIs while older clients continue to use older versions without interruption.
Q2: When should I create a new API version?
A: When you make backward-incompatible changes — like removing or renaming fields, changing response structure, or altering workflows. Minor additions (e.g., new optional fields) can usually remain in the same version.
Q3: Is URI-based versioning the best approach?
A: For most RESTful Rails apps — yes. URI-based versioning (e.g., /api/v1
) is visible, testable, and simple. It’s supported by default in routing and widely understood by developers.
Q4: Are there alternatives to URI-based versioning?
A: Yes:
- Header-based versioning: Uses
Accept
headers likeapplication/vnd.api.v2+json
. It’s more flexible but harder to debug. - Query parameter versioning: e.g.,
?version=2
— discouraged for RESTful APIs.
Q5: Do I need to duplicate everything for each version?
A: No. You only need to duplicate what changes. You can reuse models, services, and serializers, and only override behavior in version-specific controllers or serializers as needed.
Q6: Where should I keep shared logic?
A: Use service objects (app/services
), form objects, and shared modules to keep logic DRY. Only controllers and serializers should be version-specific.
Q7: Should every controller action be versioned?
A: Only those that change behavior across versions. If an action remains the same across versions, you can delegate from the new version’s controller to the old one or extract it into a shared concern/service.
Q8: How many versions should I maintain at once?
A: Usually 2: the current (latest) version and one previous stable version. You can deprecate and sunset older versions gradually based on client usage.
🛠️ Technical Q&A
Q1: How do I organize versioned controllers in Rails?
A: Use nested namespaces inside app/controllers/api/
. For example:
app/controllers/
├── api/
│ ├── v1/
│ │ └── users_controller.rb
│ └── v2/
│ └── users_controller.rb
Q2: Can I share logic between versions?
A: Yes. Keep shared logic in:
app/services/
→ for business logicapp/serializers/
→ for reusable JSON structuresapp/models/concerns/
orlib/
for reusable modules
Q3: Do I need to define separate routes for every version?
A: Yes. Use nested namespaces in routes.rb
:
namespace :api do
namespace :v1 do
resources :users
end
namespace :v2 do
resources :users
end
end
Q4: Can I version APIs using headers instead of paths?
A: Yes, but it requires custom logic in ApplicationController
or a middleware to detect headers like Accept: application/vnd.myapp.v2+json
and route accordingly. This is more complex and less transparent than URI-based versioning.
Q5: Should I copy the entire controller when creating a new version?
A: Only if the behavior changes. Otherwise, use delegation:
module Api::V2
class UsersController < Api::V1::UsersController
# override only what’s different
end
end
Q6: What’s the best way to handle version-specific serializers?
A: Place them in folders like:
app/serializers/api/v1/user_serializer.rb
app/serializers/api/v2/user_serializer.rb
Then set the serializer manually in the controller if needed:
render json: user, serializer: Api::V2::UserSerializer
Q7: How do I write tests for versioned APIs?
A: Use request specs per version:
# spec/requests/api/v1/users_spec.rb
describe "GET /api/v1/users" do
it "returns basic user data" do
get "/api/v1/users"
expect(response).to have_http_status(:ok)
end
end
Q8: How do I gracefully deprecate old versions?
A: Add a notice in the response header:
response.set_header("X-API-Deprecation-Notice", "v1 will be removed on 2025-12-01")
You can also log usage or send email alerts to third-party consumers.
✅ Best Practices
- ✅ Use
/api/v1/
path versioning for clarity - ✅ Isolate controllers per version
- ✅ Keep reusable logic in services or modules
- ✅ Document version changes clearly in your API docs
- ✅ Deprecate older versions gracefully with notices
🌍 Real-world Scenario
In a public-facing API for a mobile app, v1
supported only basic user profiles. In v2
, richer profile data (e.g., social links, avatars) was added. By versioning the API and isolating changes in api/v2/users_controller.rb
, developers shipped new features without breaking existing Android or iOS clients.
Reuse Logic via Interactors or Dry-rb
🧠 Detailed Explanation
As Rails apps grow, business logic tends to pile up inside controllers or models, making them bulky, repetitive, and difficult to test or reuse. To solve this, developers extract logic into service layers — and tools like Interactor
or dry-rb
help formalize that pattern.
📦 What is Interactor?
Interactor is a simple gem that helps you organize business logic into single-purpose classes. Each interactor class represents a unit of work. It uses:
context
for shared input/outputcall
as the main execution methodfail!
to halt execution and signal failure
You can also chain multiple interactors together using an Organizer
.
⚙️ What is dry-rb?
dry-rb
is a collection of Ruby libraries for writing clean, functional, and explicit business logic. Key pieces include:
dry-transaction
: For sequencing steps like a pipelinedry-monads
: For consistent result objects (Success
,Failure
)dry-validation
: For schema-based input validation
🔁 Why reuse logic this way?
- ✅ DRY: Avoid copy-pasting logic across controllers, jobs, and services
- ✅ Clean: Each interactor or transaction is focused and predictable
- ✅ Testable: Logic is decoupled from HTTP and database layers
- ✅ Composable: Easily organize and reuse operations as building blocks
This is especially helpful when building APIs, background jobs, CLI tasks, and workflows where clear separation and reusability are important.
🎯 When should I use them?
- 📌 When your controller or model action has 3+ steps (e.g., validate, create, email)
- 📌 When the same logic is used in multiple places
- 📌 When you want predictable error handling and flow control
- 📌 When your app’s complexity is growing fast and needs separation of concerns
🚦 How does flow control work?
Interactor: Uses context.fail!
to stop execution and return failure.
dry-rb: Uses Success()
and Failure()
to explicitly handle step results.
def call
context.user = User.create(...)
context.fail!(error: "User invalid") unless context.user.valid?
end
def persist(input)
return Failure("invalid") unless input[:email]
Success(User.create(input))
end
🧠 Summary
Whether you choose Interactor (simpler, procedural) or dry-rb (functional, more flexible), both approaches help you:
- Separate responsibilities
- Write reusable and testable code
- Organize complex business logic
📦 Step-by-Step Implementation
This guide covers both Interactor and dry-rb
usage patterns. You can choose one based on your preference and project complexity.
🔌 Step 1: Add gems to your Rails app
Add one of the following to your Gemfile
:
# For Interactor
gem 'interactor'
# For dry-transaction and dry-monads
gem 'dry-transaction'
gem 'dry-monads'
Then run:
bundle install
🧱 Step 2: Create a simple interactor
# app/interactors/create_user.rb
class CreateUser
include Interactor
def call
user = User.new(context.params)
if user.save
context.user = user
else
context.fail!(error: user.errors.full_messages)
end
end
end
🧩 Step 3: Use it in a controller
def create
result = CreateUser.call(params: user_params)
if result.success?
render json: result.user, status: :created
else
render json: { error: result.error }, status: :unprocessable_entity
end
end
🔗 Step 4: Combine logic using an Organizer (Interactor)
class RegisterUser
include Interactor::Organizer
organize CreateUser, SendWelcomeEmail, CreateAnalyticsEvent
end
⚙️ Step 5: Create a dry-transaction pipeline
# app/operations/register_user.rb
class RegisterUser
include Dry::Transaction
include Dry::Monads[:result]
step :validate
step :persist
step :notify
def validate(input)
return Failure("Email missing") unless input[:email].present?
Success(input)
end
def persist(input)
user = User.create(input)
user.persisted? ? Success(user) : Failure(user.errors.full_messages)
end
def notify(user)
UserMailer.welcome(user).deliver_later
Success(user)
end
end
📲 Step 6: Use it in your controller or job
result = RegisterUser.new.call(params)
if result.success?
render json: result.value!, status: :created
else
render json: { error: result.failure }, status: :unprocessable_entity
end
🧪 Step 7: Test the logic independently
# RSpec
describe CreateUser do
it "saves valid user" do
result = described_class.call(params: { email: "test@example.com" })
expect(result).to be_success
end
end
🎉 Done!
You’ve now set up composable, reusable business logic using Interactor or dry-rb
— making your app more maintainable and testable.
💡 Examples
Example 1: Interactor – CreateUser
# app/interactors/create_user.rb
class CreateUser
include Interactor
def call
user = User.new(context.params)
if user.save
context.user = user
else
context.fail!(error: user.errors.full_messages)
end
end
end
# Usage
result = CreateUser.call(params: { name: "Ali", email: "ali@example.com" })
result.success? # true/false
result.user # if success
result.error # if failed
Example 2: Interactor – Organizer Pattern
# app/interactors/user_onboarding.rb
class UserOnboarding
include Interactor::Organizer
organize CreateUser, SendWelcomeEmail, TrackAnalytics
end
# Usage
result = UserOnboarding.call(params: {...})
Example 3: dry-transaction – RegisterUser Pipeline
# app/operations/register_user.rb
class RegisterUser
include Dry::Transaction
include Dry::Monads[:result]
step :validate
step :persist
step :notify
def validate(input)
return Failure("Missing email") unless input[:email].present?
Success(input)
end
def persist(input)
user = User.create(input)
user.persisted? ? Success(user) : Failure(user.errors.full_messages)
end
def notify(user)
UserMailer.welcome(user).deliver_later
Success(user)
end
end
# Usage
result = RegisterUser.new.call({ email: "ali@example.com" })
result.success? # true/false
result.failure # error messages
result.value! # created user
Example 4: dry-validation with dry-transaction
class UserSchema < Dry::Validation::Contract
params do
required(:email).filled(:string)
required(:name).filled(:string)
end
end
class RegisterUser
include Dry::Transaction
include Dry::Monads[:result]
step :validate
def validate(input)
result = UserSchema.new.call(input)
result.success? ? Success(result.to_h) : Failure(result.errors.to_h)
end
end
Example 5: Test Interactor or Transaction
# spec/interactors/create_user_spec.rb
describe CreateUser do
it "creates a valid user" do
result = described_class.call(params: { name: "Ali", email: "ali@example.com" })
expect(result).to be_success
expect(result.user).to be_persisted
end
end
# spec/operations/register_user_spec.rb
describe RegisterUser do
it "fails if email missing" do
result = described_class.new.call({})
expect(result).to be_failure
expect(result.failure).to eq("Missing email")
end
end
✅ These examples show how to isolate business logic into well-defined, testable units and run them like mini pipelines.
🔁 Alternatives
- ✅ Plain Service Objects (with clear input/output)
- ✅ ActiveModel::Model + Form Objects
- ✅ Use Rails concerns for tiny reuse patterns
❓ General Questions & Answers
Q1: What’s the difference between Interactor and a plain service object?
A: Both are used to encapsulate logic, but Interactor provides structure and flow control out of the box — like context
, fail!
, and organizers
. Plain service objects offer more freedom but less built-in behavior.
Q2: Should I use Interactor or dry-transaction?
A: Use Interactor if you prefer simplicity and Rails-like syntax. Use dry-transaction if you’re comfortable with functional patterns (like monads, pipelines) and want greater flexibility, validation integration, and immutability.
Q3: When should I extract logic into interactors or transactions?
A: When your logic involves:
- Multiple steps (e.g., validate → save → notify)
- Reusable workflows (used in controllers, jobs, CLI)
- Clean error handling without deep nesting
Q4: Do Interactors work with background jobs?
A: Yes! You can call interactors from Sidekiq
or ActiveJob
just like any service class. This lets you reuse the same logic between HTTP and async jobs.
Q5: Do I need to test each interactor separately?
A: It’s a best practice. Each interactor or transaction step should be unit-tested for its inputs, outputs, and failure cases. You can also write integration tests for the full chain (e.g., the Organizer).
Q6: Can I mix Interactors with dry-rb?
A: Technically yes, but it’s better to pick one approach for consistency in your app. Interactors use imperative style, while dry-rb
leans toward functional style. Mixing may confuse team members.
Q7: What is the context
object in Interactor?
A: It’s an open struct-like object passed through each interactor in the chain. You can read and write to it with context.key
or context[:key]
. If you call fail!
, the chain stops immediately.
Q8: Are these libraries overkill for small apps?
A: Not necessarily. If your app has complex user flows (e.g., registration, checkout, integrations), using Interactors or transactions early can help avoid messy code later. For simpler apps, plain service objects may be enough.
🛠️ Technical Q&A
Q1: How do I pass data between multiple interactors?
A: Use the shared context
object. Each interactor writes to it, and the next one reads from it:
context.user = User.create(...)
context.order = Order.create(user: context.user)
Q2: How do I stop execution in an Interactor chain?
A: Use context.fail!(error: "Message")
. This immediately halts the chain and skips any remaining interactors in the organizer.
Q3: Can I reuse interactors in controllers and background jobs?
A: Yes. You can use CreateUser.call(params: ...)
inside controllers, jobs, or even console scripts. Interactors are just Ruby classes.
Q4: What is the return value of an interactor?
A: It returns a result object with:
success?
– true or falsecontext
– data passed/sharederror
– reason for failure, if any
Q5: How do I handle errors in dry-transaction
?
A: Each step must return Success(value)
or Failure(reason)
. If any step returns Failure
, the chain stops and skips the remaining steps.
def validate(input)
return Failure("Invalid email") if input[:email].blank?
Success(input)
end
Q6: What’s the difference between Success()
and Success[:key, value]
?
A: They’re both valid, depending on how you use the returned value. You can pass structured results (like a hash) or just one value. Just stay consistent across steps.
Q7: Can I test a dry-transaction step in isolation?
A: Yes! Each step is just a method. You can test it like:
step = RegisterUser.new.method(:validate)
result = step.call(email: nil)
expect(result).to be_failure
Q8: How should I organize interactors or operations in a Rails app?
A: Suggested structure:
app/
├── interactors/
│ ├── create_user.rb
│ └── register_user.rb
├── operations/
│ └── register_user.rb
Or use app/services/
if you prefer.
Q9: Can I use dry-transaction inside a class instead of include Dry::Transaction
?
A: Yes! You can define the steps as lambdas and build the transaction pipeline manually:
steps = Dry::Transaction(container: {
validate: -> input { ... },
persist: -> input { ... }
})
steps.define do
step :validate
step :persist
end
✅ Best Practices
1. Keep each interactor or step focused on one responsibility
A single interactor or dry-transaction step should do one thing: create a record, send an email, validate data, etc. This makes it easy to test and reuse.
2. Use clear naming for readability
Name interactors and operations with action-oriented names like CreateUser
, SendWelcomeEmail
, RegisterUser
, etc.
3. Favor composition over large service classes
Instead of writing a big service with lots of if/else and side effects, break it down into small interactors or transaction steps and combine them cleanly.
4. Reuse logic across layers (controllers, jobs, CLI)
Interactors and dry transactions should work independently of HTTP or background jobs. That allows reusability across controllers, workers, Rake tasks, or even test seeds.
5. Handle failures gracefully
Always return consistent error states. Use context.fail!
(Interactor) or Failure()
(dry-rb) instead of raising exceptions unless truly exceptional.
6. Avoid modifying state after failure
Once an interactor or step fails, don’t allow further changes. Respect the transactional flow to maintain predictable outcomes.
7. Validate early, side-effects last
Perform validations and checks in the first steps. Only persist data or send notifications after all preconditions are met.
8. Use monads in dry-rb to make flow explicit
Instead of relying on conditionals or nil checks, return Success
or Failure
so every result is explicit and traceable.
9. Test both individual steps and full pipelines
Unit test each step (Interactor or dry method), then write integration specs for the entire transaction or organizer to ensure end-to-end correctness.
10. Organize files by domain or context
Group your interactors or operations under folders like:
app/interactors/auth/
app/operations/payments/
This improves discoverability and aligns code with business features.
🌍 Real-world Scenario
In a subscription-based SaaS application, the team had a complex signup and onboarding flow. The steps included:
- ✔️ Validating user details and payment info
- ✔️ Creating a user and account record
- ✔️ Assigning a free trial plan or promo code
- ✔️ Sending onboarding emails
- ✔️ Tracking events in a 3rd-party analytics system
Initially, all of this logic was inside the controller, leading to 80+ lines of code with if
/else
blocks, database calls, and mailers. It was hard to test and impossible to reuse elsewhere (like in CLI tools or Sidekiq jobs).
🔁 Refactored using Interactors
The logic was split into small, composable interactors:
CreateUser
CreateAccount
ApplyTrial
SendWelcomeEmail
TrackSignupEvent
These were orchestrated using an Organizer
called OnboardUser
. The controller now simply did:
def create
result = OnboardUser.call(params: signup_params)
result.success? ? head(:created) : render json: { error: result.error }, status: 422
end
🧠 Benefits Realized
- ✅ Code became readable and focused
- ✅ Each step was reusable in CLI seeds and Sidekiq workers
- ✅ Easy to test: Unit tests per interactor, full specs for the chain
- ✅ Failures were gracefully handled with clear error messages
- ✅ Analytics and onboarding logic were decoupled from business logic
Later, the team migrated the same flow to dry-transaction
for more functional control and consistent return values using monads. The logic remained modular and easy to evolve.
💡 Summary
By refactoring controller bloat into a structured pipeline of interactors (or dry-transaction
steps), the team reduced bugs, improved clarity, and made the system future-proof for parallel systems (mobile, API, jobs, admin panels).
Request Specs (spec/requests)
🧠 Detailed Explanation
Request specs are high-level integration tests that simulate full HTTP requests to your application. They are part of RSpec
and live in the spec/requests
directory.
🧪 What do they test?
Request specs test the entire stack, including:
- ✔️ Routes (are they correctly mapped?)
- ✔️ Controllers (do they respond correctly?)
- ✔️ Middleware (e.g., JWT authentication, CSRF)
- ✔️ JSON structure or rendered views
- ✔️ API status codes and error handling
🧱 Why are they important?
In modern Rails apps — especially API-only projects — request specs replace outdated controller specs. They provide better coverage by mimicking how a real client (browser or mobile app) interacts with your app.
- ✅ They catch integration bugs that unit tests miss
- ✅ They help confirm that routes and controllers work together
- ✅ They’re great for CI pipelines and documentation validation
🔍 How are they different from other specs?
Spec Type | Tests | Layer |
---|---|---|
Model Spec | Validations, scopes | Database logic |
Request Spec | Endpoints, JSON, headers | Full stack |
System Spec | UI flows via browser | Frontend + backend |
🚀 How does it work internally?
When you use get
, post
, or put
in request specs, Rails routes the request just like it would in production — loading middleware, running controllers, and returning actual HTTP responses.
💡 Where are request specs most useful?
- 📦 API responses (testing status codes and JSON)
- 🔐 Authentication and authorization headers
- 📄 Validating error messages and edge cases
- 📤 Ensuring emails, redirects, or side effects fire correctly
📦 Summary
Request specs are essential for ensuring your application’s endpoints behave as expected. They provide real-world confidence and reduce bugs during refactoring or releases — especially in API-first or backend-heavy Rails projects.
📦 Step-by-Step Implementation
Request specs simulate real HTTP calls to your Rails app and test the full stack including routes, controllers, middleware, and serializers.
📦 Step 1: Add RSpec and Required Gems
# In Gemfile (if not already added)
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
gem 'faker'
end
# Then run:
bundle install
rails generate rspec:install
📁 Step 2: Create a Request Spec
In spec/requests/users_spec.rb
:
require 'rails_helper'
RSpec.describe "Users API", type: :request do
describe "GET /api/v1/users" do
it "returns a list of users" do
create_list(:user, 2)
get "/api/v1/users"
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body).length).to eq(2)
end
end
end
🔧 Step 3: Create a Factory for Your Model
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { Faker::Name.name }
email { Faker::Internet.email }
password { "password" }
end
end
🧪 Step 4: Test a POST Request with Params
describe "POST /api/v1/users" do
it "creates a user" do
params = { name: "Ali", email: "ali@example.com", password: "password" }
post "/api/v1/users", params: params
expect(response).to have_http_status(:created)
expect(User.last.name).to eq("Ali")
end
end
🛡️ Step 5: Test Auth Headers or Tokens
describe "GET /api/v1/profile" do
let(:user) { create(:user) }
it "returns the current user" do
token = JwtService.encode(user_id: user.id)
get "/api/v1/profile", headers: { "Authorization": "Bearer #{token}" }
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)["email"]).to eq(user.email)
end
end
📂 Step 6: Organize Your Request Specs
Keep your request specs under spec/requests/
and name them clearly:
spec/requests/users_spec.rb
spec/requests/authentication_spec.rb
spec/requests/posts_spec.rb
🚀 Step 7: Run Tests
# Run all specs
bundle exec rspec
# Run just request specs
bundle exec rspec spec/requests
# Run a specific file
bundle exec rspec spec/requests/users_spec.rb
🎉 Done!
You’ve now set up fully functional request specs in Rails using RSpec. These specs give you confidence that your API or app endpoints behave correctly, end-to-end.
💡 Examples
Example 1: Testing a GET endpoint
# spec/requests/posts_spec.rb
RSpec.describe "Posts API", type: :request do
describe "GET /api/v1/posts" do
it "returns all posts" do
create_list(:post, 3)
get "/api/v1/posts"
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body).length).to eq(3)
end
end
end
Example 2: Testing a POST request
describe "POST /api/v1/posts" do
it "creates a new post" do
params = { title: "New Post", body: "Post content" }
post "/api/v1/posts", params: params
expect(response).to have_http_status(:created)
expect(Post.last.title).to eq("New Post")
end
end
Example 3: Handling invalid input
describe "POST /api/v1/posts" do
it "returns error when title is missing" do
post "/api/v1/posts", params: { body: "No title here" }
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)["errors"]).to include("Title can't be blank")
end
end
Example 4: Using headers for JWT auth
describe "GET /api/v1/me" do
it "returns user profile" do
user = create(:user)
token = JwtService.encode(user_id: user.id)
get "/api/v1/me", headers: { "Authorization": "Bearer #{token}" }
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)["email"]).to eq(user.email)
end
end
Example 5: Using a helper to parse JSON
# spec/support/request_spec_helper.rb
module RequestSpecHelper
def json
JSON.parse(response.body)
end
end
# In spec/rails_helper.rb
RSpec.configure do |config|
config.include RequestSpecHelper, type: :request
end
# Usage
expect(json["title"]).to eq("New Post")
✅ This avoids repeating JSON.parse(response.body)
in every spec.
🔁 Alternatives
- ✅ Controller specs (deprecated for Rails 5+)
- ✅ System specs for full browser-based testing (Capybara)
- ✅ Feature specs (for end-to-end UI flows)
❓ General Questions & Answers
Q1: What is a request spec in Rails?
A: A request spec is a type of integration test that sends HTTP requests (like GET
, POST
, PUT
, DELETE
) to your Rails app to test full-stack behavior — including routing, middleware, controllers, and responses.
Q2: How are request specs different from controller specs?
A: Controller specs test a single controller action in isolation. Request specs test the entire request-response cycle, making them more realistic and preferred in modern Rails versions (Rails 5+).
Q3: Where do request specs live in a Rails app?
A: Inside spec/requests/
. Each file typically corresponds to one resource or controller, like users_spec.rb
or sessions_spec.rb
.
Q4: Should I test JSON structure in request specs?
A: Yes. Request specs are ideal for API testing — you should verify status codes, keys in the JSON response, and values (like resource attributes or error messages).
Q5: Are request specs fast?
A: They’re slower than unit specs (like model tests), but faster than full system/browser specs. They offer a great balance of speed and real-world accuracy.
Q6: Can I send headers in request specs?
A: Yes. Headers (like Authorization
, Content-Type
, etc.) can be added to any request using the headers:
argument:
get "/api/v1/profile", headers: { "Authorization" => "Bearer TOKEN" }
Q7: What types of issues can request specs catch?
A: They catch:
- ❌ Routing errors
- ❌ Missing or incorrect params
- ❌ Improper response status codes
- ❌ Broken authentication/authorization
- ❌ Inconsistent or invalid JSON structures
Q8: Should I test all endpoints?
A: You should test all public-facing and critical endpoints — especially those dealing with authentication, creation/updating of resources, and payments. Add regression tests for any bugs found in production.
🛠️ Technical Q&A
Q1: How do I send JSON payloads in request specs?
A: Use params.to_json
and set headers:
post "/api/v1/posts",
params: { title: "Test" }.to_json,
headers: { "CONTENT_TYPE" => "application/json" }
Q2: How do I test authentication headers like JWT?
token = JwtService.encode(user_id: user.id)
get "/api/v1/profile",
headers: { "Authorization" => "Bearer #{token}" }
✅ Tip: You can create a helper method to reuse this logic.
Q3: How do I parse JSON responses without repeating code?
Create a helper:
# spec/support/request_spec_helper.rb
module RequestSpecHelper
def json
JSON.parse(response.body)
end
end
Then include it in your config:
# spec/rails_helper.rb
RSpec.configure do |config|
config.include RequestSpecHelper, type: :request
end
Q4: How do I pass query parameters?
get "/api/v1/search", params: { q: "Rails" }
Q5: How do I test file uploads in a request spec?
Use Rack::Test::UploadedFile
:
file = fixture_file_upload("spec/fixtures/sample.png", "image/png")
post "/api/v1/upload", params: { image: file }
Q6: How can I reuse setup logic in request specs?
Use before
blocks and let
:
let(:user) { create(:user) }
let(:headers) { { "Authorization" => "Bearer #{token}" } }
before { post "/api/v1/login", params: { email: user.email, password: "123456" } }
Q7: Can I use RSpec shared examples in request specs?
A: Yes! Use it_behaves_like
or shared_examples
to test shared behavior like unauthenticated access.
Q8: Can I test custom headers like X-Request-ID?
A: Yes. Just pass them in the headers hash:
get "/api/v1/data", headers: { "X-Request-ID" => "abc-123" }
✅ Best Practices
1. Use `type: :request` and place files in spec/requests/
RSpec uses this tag to load the right helpers and configure test behavior. It ensures correct middleware and routing are applied.
2. Prefer descriptive spec filenames and describe blocks
Use meaningful paths like:
spec/requests/authentication_spec.rb
spec/requests/api/v1/posts_spec.rb
And describe your endpoints clearly:
describe "GET /api/v1/posts"
3. Keep tests isolated and use FactoryBot
for setup
Set up only the records you need per test. Avoid polluting global state by using create
or build_stubbed
wisely.
4. Use a json
helper method
Parsing JSON with JSON.parse(response.body)
everywhere gets messy. Create a global helper like:
def json
JSON.parse(response.body)
end
5. Always assert both status and data
Don’t stop at expect(response).to be_success
. Always validate:
expect(response).to have_http_status(:created)
expect(json["title"]).to eq("Hello World")
6. Test both success and failure paths
Write at least one negative test per endpoint (e.g., missing params, unauthorized access, invalid tokens).
7. Use shared examples for reusable flows (e.g., unauthenticated access)
shared_examples "unauthorized request" do
it "returns 401" do
subject
expect(response).to have_http_status(:unauthorized)
end
end
8. Use headers consistently and test for content types
Example:
headers = { "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
9. Use `let` and `before` blocks wisely
Keep setup code clean and DRY. But avoid deeply nested before
blocks — keep each test case readable.
10. Run request specs in CI — treat them like contracts
Especially in APIs, request specs act as contracts between frontend/backend or between services. Use them to lock in behavior before deployments.
🌍 Real-world Scenario
A fintech startup was building a RESTful Rails API for mobile banking. The API included features like:
- 🔐 User authentication (JWT-based)
- 💳 Card linking and transaction history
- 💸 Money transfers and external bank integrations
🚨 Problem
During a refactor of the authentication system, the team updated the user controller to verify tokens differently. However, they forgot to update the bank transfer controller, which still relied on the old logic.
Everything passed in development and staging because most team members tested only login and dashboard endpoints manually.
🧪 Solution
The team introduced full coverage using request specs under spec/requests/api/v1
. Each spec verified:
- ✅ Status codes for authorized and unauthorized users
- ✅ Correct error messages for missing or expired tokens
- ✅ Valid and invalid payload structures
They wrote regression tests for all edge cases, such as expired tokens, missing parameters, and incorrect account IDs. This caught the token mismatch issue immediately in CI when tested end-to-end.
🎯 Result
- 🚀 Confidence in API stability increased
- 📉 Bugs in integration layers dropped significantly
- 🧪 Specs doubled as documentation for frontend/mobile teams
From then on, every endpoint change required a request spec. Even third-party integrations like Plaid or Stripe were mocked and verified using request specs before real API keys were introduced in staging.
📦 Lesson
Request specs saved the team from shipping broken functionality in production — especially in critical flows like payments and security. They’re now treated as contracts that must never break, and are included in every pull request by default.
Factories (FactoryBot)
🧠 Detailed Explanation
FactoryBot is a test data generation tool that replaces hard-coded records or YAML fixtures with flexible and reusable factories. It is widely used in Rails test suites (RSpec, Minitest, etc.) to create instances of models with sensible default attributes.
Instead of doing this manually in every test:
User.create(name: "Ali", email: "ali@example.com", password: "123456")
You can simply use:
create(:user)
This boosts productivity, ensures consistency, and lets you focus on testing behavior rather than managing data setup.
⚡ Why FactoryBot?
- ✅ Reduces repetition in test code
- ✅ Makes tests easier to read and maintain
- ✅ Encourages reuse of model definitions
- ✅ Allows overriding attributes when needed
- ✅ Works with associations and traits
🏗️ How does it work?
You define factories (templates) in spec/factories/*.rb
. These templates include default values for model fields. You can use build
, create
, or build_stubbed
to generate test objects.
Example:
factory :user do
name { "Ali" }
email { "ali@example.com" }
password { "secret" }
end
In tests:
user = build(:user) # creates unsaved instance
user = create(:user) # creates and saves to DB
user = build_stubbed(:user) # fake record, no DB interaction
📚 Traits & Associations
FactoryBot also supports traits — reusable attribute groups for special conditions — and associations for linked models.
factory :user do
trait :admin do
role { "admin" }
end
end
create(:user, :admin)
factory :post do
title { "Hello" }
association :user
end
⚠️ What to avoid
- ❌ Avoid using
create
whenbuild
is enough (for faster tests) - ❌ Avoid complex nested associations unless needed
- ❌ Avoid factories that tightly couple test logic with too many defaults
📦 Summary
FactoryBot simplifies test setup in Rails by creating flexible, clean, and dynamic test records. It supports traits, associations, and lazy attribute evaluation, making your test suite more expressive and maintainable.
📦 Step-by-Step Implementation
📦 Step 1: Add FactoryBot to your project
Add the gem in your Gemfile
under the :development
and :test
group:
group :development, :test do
gem 'factory_bot_rails'
end
Then install:
bundle install
⚙️ Step 2: Configure RSpec to include FactoryBot methods
# spec/rails_helper.rb or spec/spec_helper.rb
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
end
This lets you use create(:user)
instead of FactoryBot.create(:user)
.
🏗️ Step 3: Create your first factory
Use the Rails generator or create the file manually:
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { "Ali" }
email { "ali@example.com" }
password { "password123" }
end
end
🧪 Step 4: Use factories in specs
# spec/models/user_spec.rb
RSpec.describe User, type: :model do
it "is valid with valid attributes" do
user = build(:user)
expect(user).to be_valid
end
end
Use create(:user)
when you need a saved record, or build(:user)
when testing validations.
🔀 Step 5: Use traits for variations
factory :user do
name { "Ali" }
email { "ali@example.com" }
password { "password" }
trait :admin do
role { "admin" }
end
end
# Usage
create(:user, :admin)
🔗 Step 6: Set up associations
If a model belongs to another (e.g., Post belongs to User):
factory :post do
title { "New Post" }
body { "Content here" }
association :user
end
Or inline:
user { create(:user) }
🚀 Step 7: Use Faker for dynamic data
# spec/factories/users.rb
factory :user do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
password { "password123" }
end
This prevents conflicts in specs that require uniqueness (e.g., emails).
🎉 Done!
You can now write cleaner and faster tests using FactoryBot. Define factories once, reuse everywhere!
💡 Examples
Example 1: Basic user factory
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { "Ali" }
email { "ali@example.com" }
password { "secret123" }
end
end
# Usage in spec
user = build(:user) # not saved
user = create(:user) # saved to DB
Example 2: Using Faker for dynamic attributes
factory :user do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
password { "password" }
end
# Ensures uniqueness and avoids duplication errors
Example 3: Overriding attributes at runtime
create(:user, email: "admin@example.com", role: "admin")
Example 4: Defining traits for variations
factory :user do
name { "Ali" }
trait :admin do
role { "admin" }
end
trait :inactive do
active { false }
end
end
# Usage
create(:user, :admin)
create(:user, :admin, :inactive)
Example 5: Association with another factory
factory :post do
title { "Post Title" }
content { "Lorem ipsum" }
association :user
end
# Automatically creates user when post is created
create(:post).user.name
Example 6: Sequence for unique fields
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
end
Example 7: Performance optimization using build_stubbed
user = build_stubbed(:user)
user.persisted? # => true
user.id # fake id
build_stubbed
is great for tests that don’t require database reads/writes. Faster and safer!
Example 8: Inline factory definition in one-off tests (less common)
FactoryBot.define do
factory :comment do
content { "Nice post!" }
post
end
end
✅ Keep these definitions centralized in spec/factories/
to avoid duplication.
🔁 Alternatives
- ✅ Fixtures (less flexible)
- ✅ Faker (used inside factories)
- ✅ Custom object builders (for specific domains)
❓ General Q&A
Q: Why use factories instead of fixtures?
A: Fixtures are static and hard to customize. Factories are dynamic, more readable, and can generate different variations quickly.
Q: Do factories slow down tests?
A: Not if used well. Avoid overusing create
(which hits the DB); prefer build
or build_stubbed
when DB persistence isn’t needed.
🛠️ Technical Q&A
Q: What’s the difference between create
, build
, and build_stubbed
?
create(:user)
– saves to DBbuild(:user)
– unsaved objectbuild_stubbed(:user)
– mock without touching DB
Q: How do I associate factories?
factory :post do
title { "Post title" }
association :user
end
✅ Best Practices
- ✅ Use
create
only when persistence is required - ✅ Use
build
orbuild_stubbed
for fast tests - ✅ Extract traits for common scenarios (e.g.,
:admin
,:published
) - ✅ Use Faker for unique data (e.g., emails)
- ✅ Avoid creating unnecessary associations if not tested
🌍 Real-world Scenario
In a Rails API project, FactoryBot was used to generate test users, posts, and tokens across hundreds of request and model specs. Traits like :admin
and :expired_token
made it easy to test authorization and edge cases without repeating setup code. It dramatically reduced boilerplate and made the test suite readable, fast, and scalable.
Authenticated Requests
🧠 Detailed Explanation
When you’re building an API, some routes (like /login
or /register
) are open to everyone.
But others — like /profile
, /payments
, or /dashboard
— should only be accessed by logged-in users.
These protected routes require users to be authenticated. In simple terms, the system needs to know: “Who is making this request?”
🔐 How does authentication work?
In APIs, users are usually identified by sending a token in the request. That token might be:
- ✅ A JWT (JSON Web Token)
- ✅ An API key
- ✅ A session cookie (in browser-based apps)
The token is sent in the request headers — usually under Authorization
.
The server reads the token, checks if it’s valid, and finds the matching user.
GET /api/v1/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
🧪 What is an authenticated request in tests?
In a test, you’re pretending to be a logged-in user. So, you generate a token for a test user and include it in your test request.
user = create(:user)
token = JwtService.encode(user_id: user.id)
get "/api/v1/profile", headers: { "Authorization" => "Bearer #{token}" }
The controller will treat this request the same as a real user with a token. You can now test if the system:
- ✅ Lets the user in (returns profile info)
- ❌ Blocks access without a token
- ❌ Rejects invalid or expired tokens
🔄 Summary
Authenticated requests in Rails testing simulate what happens when a real user accesses your app with a token. They’re essential for testing secure parts of your API like profiles, payments, and account settings.
✅ You create a test user → 🛡️ Generate a token → 🧪 Use it in the request → 🔍 Check the response.
📦 Step-by-Step Implementation
🔐 Step 1: Setup authentication (JWT or Devise)
If using JWT, you need a way to issue and decode tokens. You might use a service class like:
# app/services/jwt_service.rb
class JwtService
SECRET_KEY = Rails.application.secrets.secret_key_base.to_s
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY)[0]
HashWithIndifferentAccess.new(decoded)
rescue
nil
end
end
👤 Step 2: Create a user factory
# spec/factories/users.rb
FactoryBot.define do
factory :user do
email { "user@example.com" }
password { "password123" }
end
end
🔧 Step 3: Add helper to generate auth headers
# spec/support/auth_helper.rb
module AuthHelper
def auth_headers(user)
token = JwtService.encode(user_id: user.id)
{ "Authorization" => "Bearer #{token}" }
end
end
# Include it in RSpec
# spec/rails_helper.rb
RSpec.configure do |config|
config.include AuthHelper, type: :request
end
🧪 Step 4: Write a request spec for an authenticated endpoint
# spec/requests/profile_spec.rb
RSpec.describe "GET /api/v1/profile", type: :request do
let(:user) { create(:user) }
it "returns user profile for authorized user" do
get "/api/v1/profile", headers: auth_headers(user)
expect(response).to have_http_status(:ok)
expect(json["email"]).to eq(user.email)
end
it "returns 401 for missing token" do
get "/api/v1/profile"
expect(response).to have_http_status(:unauthorized)
end
end
🚀 Step 5: Make your controller use authentication
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
before_action :authorize_request
def authorize_request
header = request.headers["Authorization"]
token = header.split(" ").last if header
decoded = JwtService.decode(token)
@current_user = User.find_by(id: decoded[:user_id]) if decoded
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
end
end
✅ Step 6: Use shared helpers in all authenticated specs
This keeps your tests clean and reusable:
headers = auth_headers(create(:user))
get "/api/v1/data", headers: headers
🎉 Done!
You now have a robust setup for writing authenticated API request specs in Rails. This supports token-based systems (like JWT) and can easily be adapted to Devise, OAuth, or API key schemes.
💡 Examples
Example 1: Authenticated API request with JWT
RSpec.describe "GET /api/v1/profile", type: :request do
let(:user) { create(:user) }
let(:token) { JwtService.encode(user_id: user.id) }
it "returns the profile for authorized user" do
get "/api/v1/profile", headers: { "Authorization" => "Bearer #{token}" }
expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)["email"]).to eq(user.email)
end
end
Example 2: Unauthorized access without token
it "returns 401 if token is missing" do
get "/api/v1/profile"
expect(response).to have_http_status(:unauthorized)
end
Example 3: Use shared helper for clean tests
# spec/support/auth_helper.rb
module AuthHelper
def auth_headers(user)
token = JwtService.encode(user_id: user.id)
{ "Authorization" => "Bearer #{token}" }
end
end
# Include in rails_helper.rb
RSpec.configure do |config|
config.include AuthHelper, type: :request
end
# Then use in specs:
get "/api/v1/dashboard", headers: auth_headers(user)
Example 4: Devise-based sign in (non-API app)
# If you're using Devise and testing HTML/session-based routes
RSpec.describe "Dashboard", type: :request do
let(:user) { create(:user) }
it "allows signed-in user to view dashboard" do
sign_in user
get "/dashboard"
expect(response).to have_http_status(:ok)
end
end
Example 5: Expired or invalid token
it "returns 401 for expired token" do
expired_token = JwtService.encode({ user_id: user.id }, 1.minute.ago)
get "/api/v1/profile", headers: { "Authorization" => "Bearer #{expired_token}" }
expect(response).to have_http_status(:unauthorized)
end
✅ This confirms that your system correctly rejects expired tokens.
🔁 Alternatives
- ✅ Session-based auth (using
sign_in
for Devise in system tests) - ✅ API key header authentication
- ✅ Cookie-based token or CSRF token for browser-style requests
❓ General Q&A
Q: How do I authenticate users in request specs?
A: Generate a valid token or login session in your test, then send it in the request headers or cookies.
Q: Can I test both authorized and unauthorized cases?
A: Yes. It’s best practice to test success (authorized) and failure (unauthorized) cases for each endpoint.
🛠️ Technical Q&A
Q: How do I simulate a login using Devise?
# With Devise helpers
sign_in user
get "/dashboard"
Q: What if I’m using a custom JWT-based auth system?
A: Use your JWT generator and pass the token like:
headers = { "Authorization" => "Bearer #{JwtService.encode(user_id: user.id)" }
✅ Best Practices
- ✅ Reuse auth token generation in a helper method
- ✅ Always test both valid and invalid token scenarios
- ✅ Keep auth headers consistent across all specs
- ✅ Extract auth headers to a shared helper
🌍 Real-world Scenario
In a Rails API for a fintech app, every request required a valid JWT. Request specs simulated token-based authentication across endpoints like payments, accounts, and transactions. The team built shared helpers to inject tokens, which reduced test duplication and caught expired-token bugs early in CI.
JSON Response Structure Tests
🧠 Detailed Explanation
When your Rails app is used as an API — like for a frontend or mobile app — it returns data in JSON format. This JSON must have a consistent structure so that the frontend knows exactly where to find things like:
- 🔑 IDs
- 📄 Titles or descriptions
- 👤 User info
- 📅 Dates
If that structure changes (for example: a key is removed or renamed), the app consuming your API can break. That’s why it’s important to test the structure of your JSON responses — not just the values.
🎯 What do we check in JSON structure tests?
You typically want to test:
- ✅ If required keys (like
id
,title
,user
) are present - ✅ If nested objects are structured correctly (e.g., a
user
inside apost
) - ✅ If arrays contain expected fields (e.g., list of
comments
) - ✅ That sensitive keys like
password
ortoken
are not included
📦 How do we test it in Rails?
In RSpec, after you hit an endpoint using get
, post
, etc., you can parse the response and inspect the JSON:
get "/api/v1/posts/1"
json = JSON.parse(response.body)
expect(json).to include("title", "body", "user")
💡 Why is this important?
- 📱 Frontend/mobile developers rely on the shape of your JSON
- 🔒 Prevent breaking changes by accident
- 🚨 Catch missing or extra fields early in development
- ✅ Tests act as documentation for what your API returns
💬 Summary
JSON structure tests make sure your API always responds in a predictable way. Think of it like: “When someone asks for a post, are we sending back exactly what we promised?”
✅ These tests are simple but powerful — and they help prevent bugs that are hard to trace later.
📦 Step-by-Step Implementation
🔧 Step 1: Create or prepare your request spec file
You typically place your spec inside spec/requests/
. For example:
# spec/requests/posts_spec.rb
RSpec.describe "GET /api/v1/posts", type: :request do
end
🧱 Step 2: Setup your test data using FactoryBot
let(:user) { create(:user) }
let(:post) { create(:post, user: user) }
🌐 Step 3: Perform the API request
before do
get "/api/v1/posts/#{post.id}"
end
📥 Step 4: Parse the JSON response
let(:json) { JSON.parse(response.body) }
Optionally, you can add this helper in spec/support/request_helper.rb
:
def json
JSON.parse(response.body)
end
…and include it globally:
# spec/rails_helper.rb
RSpec.configure do |config|
config.include RequestHelper, type: :request
end
🧪 Step 5: Write JSON structure expectations
it "returns the correct post structure" do
expect(response).to have_http_status(:ok)
expect(json).to include(
"id" => post.id,
"title" => post.title,
"body" => post.body,
"user" => hash_including(
"id" => user.id,
"email" => user.email
)
)
end
🔁 Step 6: Test arrays or nested structures
it "returns a list of comments" do
expect(json["comments"]).to be_an(Array)
expect(json["comments"].first).to include("body", "user")
end
🚫 Step 7: Assert missing or invalid keys
expect(json).not_to have_key("password")
expect(json).not_to have_key("token")
🎯 Done!
You now have a solid approach to testing JSON response structure in Rails using RSpec. These tests ensure your frontend or mobile app won’t break if the response format changes unexpectedly.
💡 Examples
Example 1: Testing a flat JSON object
# GET /api/v1/posts/1 returns:
# { "id": 1, "title": "Hello", "body": "Content here" }
it "returns expected keys for a post" do
get "/api/v1/posts/#{post.id}"
json = JSON.parse(response.body)
expect(json).to include("id", "title", "body")
end
Example 2: Testing nested user object inside a post
# Response:
# { "id": 1, "title": "Hello", "user": { "id": 2, "email": "user@example.com" } }
expect(json["user"]).to include("id", "email")
expect(json["user"]["email"]).to eq(post.user.email)
Example 3: Testing array of objects (e.g., comments)
# Response:
# { "comments": [{ "body": "Nice post", "user": { "id": 3 } }] }
expect(json["comments"]).to be_an(Array)
expect(json["comments"].first).to include("body", "user")
expect(json["comments"].first["user"]).to include("id")
Example 4: Ensuring sensitive fields are excluded
# Ensure password is not sent in the user object
expect(json["user"]).not_to have_key("password")
expect(json["user"]).not_to have_key("encrypted_password")
Example 5: Using hash_including
for flexible structure matching
expect(json).to match(
hash_including(
"id" => post.id,
"title" => post.title,
"user" => hash_including("email" => post.user.email)
)
)
Example 6: Writing a helper method to DRY JSON parsing
# spec/support/request_spec_helper.rb
module RequestSpecHelper
def json
JSON.parse(response.body)
end
end
# Use in your spec:
get "/api/v1/posts/1"
expect(json).to include("title", "body")
🔁 Alternatives
- ✅ Use
json-schema
gem for strict schema enforcement - ✅ Use
rspec-json_expectations
for expressive matchers - ✅ Contract testing with
Pact
orOpenAPI RSpec
❓ General Q&A
Q: Why test the structure of JSON responses?
A: To make sure frontend/mobile apps don’t break when you change backend logic. Structure tests act as contracts between services.
Q: Do I need to test every field?
A: You should test critical keys and nested structures that are relied upon. Use partial matchers like include
or match
.
🛠️ Technical Q&A
Q: How can I check if a key exists?
expect(json).to have_key("user")
Q: How do I validate nested JSON arrays?
expect(json["comments"]).to be_an(Array)
expect(json["comments"].first).to include("body", "user")
✅ Best Practices
- ✅ Parse response JSON using a helper method
- ✅ Test presence of critical keys only (not all keys)
- ✅ Use
hash_including
andmatch_array
for flexibility - ✅ Keep response structure consistent across versions
- ✅ Use shared examples to reduce repetition
🌍 Real-world Scenario
A mobile app team integrated with a Rails backend. During a refactor, a developer removed a key from the JSON response without realizing it was critical for Android. This broke the app in production. The team added structure tests to all response specs to ensure required keys were never silently removed again.
Rate Limiting (Rack::Attack)
🧠 Detailed Explanation
Rate limiting is a way to protect your app from being overwhelmed by too many requests — either from a real user or a bot. It’s like saying: “Hey, you can only try this 5 times per minute, then take a break.”
For example, if someone tries to log in 100 times in a row, it might be a hacker. Rate limiting helps you stop that by blocking or slowing down their access.
🔒 What is Rack::Attack?
Rack::Attack
is a gem that plugs into your Rails app and acts like a security guard. It watches every request and keeps count based on things like:
- ✅ The user’s IP address
- ✅ The request path (e.g.,
/login
) - ✅ How often something is being accessed
If someone is being too aggressive (like hitting the login route too fast), it will respond with:
HTTP 429 Too Many Requests
🔧 What does it look like?
throttle('logins/ip', limit: 5, period: 60.seconds) do |req|
req.ip if req.path == '/login' && req.post?
end
This means: Allow only 5 login POST requests per IP address every 60 seconds.
💡 Why use Rack::Attack?
- 🛡️ Protects from bots and brute-force attacks
- 🌐 Can block bad IPs or countries
- ⏱️ Controls request speed without needing an external service
- 🔍 Easy to log, customize, and test
📦 How does it work?
It uses your app’s cache (like Redis or memory store) to remember how many times a user has hit a specific route. If the user goes over the limit, Rack::Attack stops their request before it hits your app logic.
✅ Summary
Rack::Attack is like a bouncer at your API’s door. It tracks requests and keeps the bad ones out. It’s simple to add, easy to configure, and very helpful when your app is getting spammed or attacked.
📦 Step-by-Step Implementation
📦 Step 1: Add Rack::Attack to your Gemfile
# Gemfile
gem 'rack-attack'
Then run:
bundle install
⚙️ Step 2: Enable middleware in Rails
Rails adds it automatically in production, but you can also enable it explicitly:
# config/application.rb
config.middleware.use Rack::Attack
🛠️ Step 3: Create initializer to define rules
Create a file:
# config/initializers/rack_attack.rb
Define throttling rules like:
class Rack::Attack
# Throttle login requests by IP
throttle("logins/ip", limit: 5, period: 60.seconds) do |req|
if req.path == "/login" && req.post?
req.ip
end
end
# Block specific IP
blocklist("block 1.2.3.4") do |req|
req.ip == "1.2.3.4"
end
# Allow localhost/internal services
safelist("allow localhost") do |req|
["127.0.0.1", "::1"].include?(req.ip)
end
end
📦 Step 4: Configure cache store (recommended Redis)
For distributed rate tracking across servers, use Redis:
# config/environments/production.rb
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }
# Also in development for testing
config.cache_store = :memory_store
🔍 Step 5: Customize rate-limit responses (optional)
You can override the 429 response with a friendly message:
Rack::Attack.throttled_response = lambda do |env|
[
429,
{ "Content-Type" => "application/json" },
[{ error: "Rate limit exceeded. Try again later." }.to_json]
]
end
🧪 Step 6: Test it in RSpec
RSpec.describe "Rate limiting", type: :request do
it "blocks after too many login attempts" do
6.times do
post "/login", params: { email: "user@example.com", password: "wrong" }
end
expect(response).to have_http_status(429)
expect(response.body).to include("Rate limit exceeded")
end
end
🛡️ Step 7: Monitor and refine
- ✅ Check logs to see rate-limited traffic
- ✅ Adjust
limit
andperiod
based on real-world usage - ✅ Add safelists for internal tools or CI pipelines
🎉 Done!
You’ve now implemented basic rate limiting using Rack::Attack
in your Rails app to block abuse and keep your app healthy and secure 🚀.
💡 Examples
Example 1: Throttle login attempts per IP
# Limit to 5 login attempts per minute per IP
throttle('logins/ip', limit: 5, period: 60.seconds) do |req|
if req.path == '/login' && req.post?
req.ip
end
end
👆 This blocks brute-force password guessing.
Example 2: Throttle all requests per IP globally
# Limit all requests to 300 per 5 minutes per IP
throttle('req/ip', limit: 300, period: 5.minutes) do |req|
req.ip
end
👆 Protects the app from traffic floods.
Example 3: Block specific abusive IP
blocklist('block 192.0.2.1') do |req|
req.ip == '192.0.2.1'
end
👆 Instantly denies all requests from a specific IP.
Example 4: Safelist trusted services
safelist('allow localhost') do |req|
['127.0.0.1', '::1'].include?(req.ip)
end
👆 Allows internal traffic to bypass limits.
Example 5: Custom response for rate-limited users
Rack::Attack.throttled_response = lambda do |env|
[
429,
{ 'Content-Type' => 'application/json' },
[{ error: 'Rate limit exceeded. Please try again later.' }.to_json]
]
end
👆 Provides a friendly error message when users hit the limit.
Example 6: Count requests using Redis (best for production)
# config/environments/production.rb
config.cache_store = :redis_cache_store, { url: ENV["REDIS_URL"] }
👆 Required for shared rate limits across multiple servers.
🔁 Alternatives
- ✅ Nginx or AWS API Gateway rate limiting
- ✅ Cloudflare Firewall Rules
- ✅ Custom Redis-based throttling logic
❓ General Q&A
Q: What happens when a user hits the rate limit?
A: They receive a 429 Too Many Requests
HTTP response.
Q: Does Rack::Attack store data?
A: Yes, it uses your Rails cache store (e.g., Redis or memory store) to track requests.
🛠️ Technical Q&A
Q: How can I test Rack::Attack in RSpec?
it "throttles excessive login attempts" do
6.times do
post "/login", params: { email: "test@example.com", password: "wrong" }
end
expect(response).to have_http_status(429)
end
Q: How do I whitelist internal services?
Rack::Attack.safelist('allow localhost') do |req|
req.ip == '127.0.0.1'
end
✅ Best Practices
- ✅ Store rate-limit counts in Redis for multi-server setups
- ✅ Use environment-specific rules (more strict in production)
- ✅ Allow whitelisting for internal tools and monitoring services
- ✅ Customize 429 responses with proper headers and messages
- ✅ Test rate limiting in staging environments before production
🌍 Real-world Scenario
A public Rails API was being targeted with brute-force login attempts, slowing down the entire app.
The team introduced Rack::Attack
to limit failed login attempts by IP and added custom messages in the 429 response.
It significantly reduced server load and blocked malicious activity with zero downtime.
Pagination (Kaminari, Pagy)
🧠 Detailed Explanation
When your app has too many records (like thousands of users or posts), showing them all at once is a bad idea. It can crash browsers, slow down loading, and waste bandwidth.
Pagination is the solution. It breaks data into smaller chunks (pages), like:
- Page 1 → Users 1–10
- Page 2 → Users 11–20
- Page 3 → Users 21–30
When someone visits a page or hits your API with ?page=2
, only a limited number of records are returned. This keeps your app fast and efficient.
🔍 What tools can we use?
- Kaminari – Popular and works great with views and helpers
- Pagy – Newer, faster, minimal memory, and great for APIs
📦 How does it work?
You use a gem (Kaminari or Pagy) to:
- Specify
page
andper_page
values in the controller - Render only a small set of records
- Optionally return pagination metadata like total pages, current page, etc.
# Example (Pagy)
@pagy, @users = pagy(User.all, items: 10)
# Will return only 10 users for the current page
💡 When do I need it?
- 📱 APIs returning large data (e.g. mobile apps)
- 📄 UI tables with 50+ entries
- 🔍 Admin panels, dashboards, blog listings, etc.
🧪 What’s in a paginated API response?
In a JSON API, you usually send back:
- 🔢 The actual data (users, posts, etc.)
- 📄 Pagination metadata (
current_page
,total_pages
,next
, etc.)
{
"data": [...],
"meta": {
"current_page": 2,
"total_pages": 5
}
}
📋 Summary
Pagination helps your app stay fast and responsive by loading only a few records at a time. Gems like Kaminari and Pagy make it super easy to add in your controller and views or APIs.
✅ It’s essential for any growing app — and one of the easiest performance wins you can get!
📦 Step-by-Step Implementation
📦 Step 1: Add the pagination gem to your Gemfile
Pick one of the two gems:
# For Kaminari
gem 'kaminari'
# Or for Pagy (lightweight & fast)
gem 'pagy'
Then run:
bundle install
📂 Step 2: Include necessary modules
For Pagy, include modules in your controller:
# app/controllers/application_controller.rb
include Pagy::Backend
For views:
# app/helpers/application_helper.rb or directly in views
include Pagy::Frontend
🧮 Step 3: Use pagination in controller
Kaminari Example:
@users = User.page(params[:page]).per(10)
Pagy Example:
@pagy, @users = pagy(User.all, items: 10)
🖥️ Step 4: Display pagination in views
Kaminari:
<%= paginate @users %>
Pagy:
<%= pagy_nav(@pagy) %>
🧪 Step 5: Return pagination in API responses
# Pagy + JSON response format
@pagy, @users = pagy(User.all)
render json: {
data: @users,
meta: {
current_page: @pagy.page,
total_pages: @pagy.pages,
items: @pagy.items
}
}
🧼 Step 6: Add pagination params validation (optional)
params[:page] = params[:page].to_i <= 0 ? 1 : params[:page]
params[:per_page] = [params[:per_page].to_i, 100].min
👆 Avoid performance issues from invalid or huge values.
🔍 Step 7: Test in RSpec
get "/api/v1/users", params: { page: 2 }
expect(response).to have_http_status(:ok)
expect(json["meta"]["current_page"]).to eq(2)
expect(json["data"].length).to be <= 10
🎯 Done!
You’ve now implemented clean, flexible pagination using either Kaminari or Pagy — great for both UI views and JSON APIs.
💡 Examples
Example 1: Kaminari in a Rails controller
# app/controllers/users_controller.rb
def index
@users = User.page(params[:page]).per(10)
end
In the view:
<%= paginate @users %>
Example 2: Pagy for performance-focused pagination
# app/controllers/users_controller.rb
include Pagy::Backend
def index
@pagy, @users = pagy(User.all, items: 20)
end
In the view:
<%= pagy_nav(@pagy) %>
Example 3: Paginating JSON API with Pagy
# app/controllers/api/v1/users_controller.rb
def index
@pagy, @users = pagy(User.all, items: 10)
render json: {
data: @users,
meta: {
current_page: @pagy.page,
total_pages: @pagy.pages,
items: @pagy.items
}
}
end
Example 4: Allow clients to control per_page (with a limit)
per_page = [params[:per_page].to_i, 50].min
@pagy, @users = pagy(User.all, items: per_page)
✅ This avoids abuse while giving flexibility.
Example 5: RSpec test for paginated JSON response
get "/api/v1/users", params: { page: 2, per_page: 10 }
expect(response).to have_http_status(:ok)
expect(json["meta"]["current_page"]).to eq(2)
expect(json["data"].length).to be <= 10
Example 6: Kaminari view helpers with custom labels
<%= paginate @users, theme: 'bootstrap-4' %>
🔧 You can customize pagination look & feel easily with built-in themes.
🔁 Alternatives
- ✅ WillPaginate (less common now)
- ✅ Custom SQL LIMIT/OFFSET logic
- ✅ Cursor-based pagination (for large APIs)
❓ General Q&A
Q: Why not return all records at once?
A: Large datasets slow down performance and cause memory issues. Pagination helps limit results and improves scalability.
Q: Which is better: Kaminari or Pagy?
A: Pagy is faster and better for APIs; Kaminari is more customizable and works well with views.
🛠️ Technical Q&A
Q: How do I test pagination in RSpec?
get "/api/v1/users", params: { page: 2 }
expect(json["data"].count).to eq(10)
expect(json["meta"]["current_page"]).to eq(2)
Q: Can I paginate ActiveRecord scopes?
A: Yes! Both Kaminari and Pagy work on any ActiveRecord scope or relation.
✅ Best Practices
- ✅ Always include
meta
info (like total pages, current page) in API responses - ✅ Allow
per_page
to be customized with max limits - ✅ Use
Pagy::Countless
for huge datasets - ✅ Handle edge cases (invalid page numbers or 0 records)
🌍 Real-world Scenario
A Rails API returning 10,000+ records for a mobile app began slowing down and crashing devices.
The team implemented Pagy to return 20 results per page with meta
data.
This improved performance, and the frontend only loaded what was needed, reducing API load by 80%.
Filtering & Search (Ransack, Custom Scopes)
🧠 Detailed Explanation
Imagine you’re building a user listing page or an admin dashboard. You want to show a list of users, but also allow people to:
- 🔍 Search by email or name
- 🎯 Filter by role (e.g., admin, editor)
- 🟢 Only show active users
This is called filtering and searching. It lets users narrow down large datasets to just what they need.
💡 Two ways to do this in Rails
- ✅ Ransack: A powerful gem that generates filters and search forms automatically. It works with both views and APIs.
- ✅ Custom scopes: Write your own filtering logic using ActiveRecord. Gives you full control and flexibility.
🔎 What is Ransack?
Ransack is a gem that makes it easy to add filtering/searching forms in your app. You don’t need to write a lot of code.
You just call Model.ransack(params[:q])
, and it does the rest.
@q = User.ransack(params[:q])
@users = @q.result
Then in your view, you can use search_form_for
to create a search form without needing to write any SQL or conditions.
🛠 What are custom scopes?
Custom scopes are small methods in your model that let you write clean and reusable filters. For example, if you want to filter only active users or users with a specific email:
scope :active, -> { where(active: true) }
scope :by_email, ->(email) { where("email ILIKE ?", "%#{email}%") }
Then in the controller:
@users = User.active.by_email(params[:email])
📦 When should I use which?
- ✅ Use Ransack if you want powerful form-based searching with many fields (e.g., search by name, role, created_at...)
- ✅ Use custom scopes if you want clean and fast filtering with full control, especially in APIs
📋 Summary
Filtering and search helps users find what they need quickly. Ransack gives you a plug-and-play solution for complex filters, while custom scopes give you clean, focused control for performance and logic.
🔍 Whether you’re building a dashboard, a search form, or a REST API — filtering is one of the most helpful features to add.
📦 Step-by-Step Implementation
📦 Step 1: Add Ransack to your Gemfile (optional if using it)
# Gemfile
gem 'ransack'
Then run:
bundle install
🧠 Step 2: Create a filterable controller action
Using Ransack (easiest):
# app/controllers/users_controller.rb
def index
@q = User.ransack(params[:q])
@users = @q.result(distinct: true)
end
Using custom scopes:
def index
@users = User.all
@users = @users.where(role: params[:role]) if params[:role].present?
@users = @users.by_email(params[:email]) if params[:email].present?
end
🎨 Step 3: Add a search form (for Ransack)
# app/views/users/index.html.erb
<%= search_form_for @q, url: users_path, method: :get do |f| %>
<%= f.label :email_cont, "Email contains" %>
<%= f.search_field :email_cont %>
<%= f.label :role_eq, "Role" %>
<%= f.select :role_eq, User.pluck(:role).uniq, include_blank: true %>
<%= f.submit "Filter" %>
<% end %>
🔍 Step 4: Define custom scopes (for custom filtering)
# app/models/user.rb
scope :by_email, ->(email) { where("email ILIKE ?", "%#{email}%") }
scope :by_status, ->(status) { where(status: status) }
📊 Step 5: Use filters in an API endpoint
def index
@q = User.ransack(params[:q])
@users = @q.result.page(params[:page])
render json: {
data: @users,
filters: params[:q],
meta: {
total: @users.count
}
}
end
✅ Step 6: Add query param documentation (for APIs)
Example API usage:
GET /api/v1/users?q[email_cont]=john&q[role_eq]=admin
💡 Add this to your API docs so clients know how to filter records properly.
🎉 Done!
You’ve now implemented both Ransack-based and custom-scope-based filtering in your Rails app. Whether you're building admin dashboards, public search pages, or JSON APIs, this approach keeps your code clean and flexible.
💡 Examples
Example 1: Ransack search form in a Rails view
# Controller
def index
@q = User.ransack(params[:q])
@users = @q.result(distinct: true)
end
# View (ERB)
<%= search_form_for @q, url: users_path, method: :get do |f| %>
<%= f.label :email_cont, "Email contains" %>
<%= f.search_field :email_cont %>
<%= f.label :role_eq, "Role" %>
<%= f.select :role_eq, User.pluck(:role).uniq, include_blank: true %>
<%= f.submit "Search" %>
<% end %>
🔎 This creates a dynamic form that filters users by email and role.
Example 2: Ransack in a JSON API
# GET /api/v1/users?q[email_cont]=john&q[role_eq]=admin
def index
@q = User.ransack(params[:q])
@users = @q.result
render json: {
data: @users,
filters: params[:q]
}
end
✅ Useful for dynamic client-side filtering in React/Vue/Flutter apps.
Example 3: Custom scopes in the model
# app/models/user.rb
scope :by_email, ->(email) { where("email ILIKE ?", "%#{email}%") }
scope :active, -> { where(active: true) }
scope :by_role, ->(role) { where(role: role) }
In controller:
@users = User.all
@users = @users.by_email(params[:email]) if params[:email].present?
@users = @users.by_role(params[:role]) if params[:role].present?
@users = @users.active if params[:active] == "true"
💪 Gives you full control with clean, testable code.
Example 4: Combining filters with pagination (Pagy)
include Pagy::Backend
def index
@q = User.ransack(params[:q])
@pagy, @users = pagy(@q.result(distinct: true))
render json: {
data: @users,
meta: {
current_page: @pagy.page,
total_pages: @pagy.pages
}
}
end
🔥 This is ideal for high-performance APIs or admin dashboards.
Example 5: Filtering with checkboxes (e.g., status)
# View
<%= f.check_box :status_eq, { multiple: true }, "active", nil %>
<%= f.label :status_eq_active, "Active" %>
<%= f.check_box :status_eq, { multiple: true }, "inactive", nil %>
<%= f.label :status_eq_inactive, "Inactive" %>
📦 Ransack makes complex filters feel simple to implement.
🔁 Alternatives
- ✅ Searchkick + Elasticsearch (for full-text search)
- ✅ PgSearch (PostgreSQL full-text search)
- ✅ Custom SQL/AR Scopes for fast, lightweight filters
❓ General Q&A
Q: Do I need Ransack for simple filters?
A: No, simple filters like where(role: "admin")
can be written with scopes or params. Use Ransack for multi-field, form-driven filtering.
Q: Can I use Ransack in APIs?
A: Yes, Ransack works with query params like ?q[name_cont]=john
and returns filtered results in JSON.
🛠️ Technical Q&A
Q: How do I make a reusable scope?
# app/models/user.rb
scope :active, -> { where(active: true) }
scope :by_email, ->(email) { where("email ILIKE ?", "%#{email}%") }
Q: Can Ransack filter nested associations?
@q = Post.ransack(author_name_cont: "Saad")
A: Yes! Ransack supports associations via naming like author_name_eq
.
✅ Best Practices
- ✅ Use Ransack for admin dashboards and complex multi-field filters
- ✅ Use scopes for simple and frequent filters (e.g.,
published
,active
) - ✅ Sanitize any custom SQL-like conditions to avoid injection
- ✅ Return paginated results to avoid performance issues
- ✅ For APIs, document accepted filter params clearly
🌍 Real-world Scenario
An internal admin panel for an e-commerce app needed filters for orders by status, date, customer name, and amount. The team used Ransack to build a filter sidebar with one-liner filters in the view. This saved dev time, and allowed non-technical admins to search with ease.
Background Jobs (Sidekiq, ActiveJob)
🧠 Detailed Explanation
When a user clicks something on your site — like placing an order, signing up, or uploading a file — you usually want to respond quickly. But some tasks (like sending confirmation emails, generating PDFs, or syncing with an external API) take time.
If you run these tasks directly in your controller, the user will have to wait until they finish 😕. This is a bad user experience, especially if the task takes more than a second or two.
💡 The Solution: Background Jobs
Background jobs let your app send slow work to the background so your app can keep responding quickly. Instead of doing the task now, you say: "Do this later, behind the scenes."
Here’s how it works:
- 👤 User clicks "Sign Up"
- ✅ Your app creates the user instantly
- 📬 A background job sends the welcome email later
⚙️ What is ActiveJob?
ActiveJob is the built-in Rails system for creating and managing background jobs. It gives you a clean way to define jobs like this:
class NotifyUserJob < ApplicationJob
def perform(user)
UserMailer.welcome_email(user).deliver_now
end
end
You don’t need to worry about how or when the job runs — just tell Rails to perform_later
.
🚀 What is Sidekiq?
Sidekiq is a tool that actually runs your background jobs. It uses Redis and multiple threads to run many jobs at once, very fast.
It works perfectly with ActiveJob and lets you:
- 🔁 Retry failed jobs automatically
- ⏱ Schedule jobs for the future
- 📊 Monitor job queues and status via a web dashboard
🔗 How do they work together?
You define jobs using ActiveJob syntax, and Sidekiq runs them in the background.
# In controller
NotifyUserJob.perform_later(@user)
# Sidekiq runs it behind the scenes
📋 Summary
Using background jobs keeps your app fast and clean. ActiveJob is the way to define jobs in Rails, and Sidekiq is the engine that runs them.
✅ Use background jobs to send emails, export data, notify users, or call external services — without slowing down your app.
📦 Step-by-Step Implementation
📦 Step 1: Add Sidekiq to your Gemfile
# Gemfile
gem 'sidekiq'
Then run:
bundle install
⚙️ Step 2: Set ActiveJob adapter to Sidekiq
Tell Rails to use Sidekiq for background jobs:
# config/application.rb
config.active_job.queue_adapter = :sidekiq
💡 Step 3: Create your job
# Terminal
rails g job NotifyUser
# app/jobs/notify_user_job.rb
class NotifyUserJob < ApplicationJob
queue_as :default
def perform(user)
UserMailer.welcome_email(user).deliver_now
end
end
🚀 Step 4: Enqueue the job
Inside a controller, model, or service:
NotifyUserJob.perform_later(@user)
💡 perform_later
queues the job in Sidekiq. perform_now
runs it immediately.
⚡ Step 5: Run Sidekiq
# Terminal
bundle exec sidekiq
Make sure Redis is running locally too:
redis-server
🖥️ Step 6: Add Sidekiq Web UI (optional)
# config/routes.rb
require 'sidekiq/web'
mount Sidekiq::Web => '/sidekiq'
🛡️ You can secure it using Devise or basic auth in production.
🧪 Step 7: Test your job works
Trigger a job and check /sidekiq
or terminal output:
NotifyUserJob.perform_later(User.last)
🎯 You should see Sidekiq pick up and execute the job.
📦 Step 8: Optional job features
# Retry job 3 times with delay
retry_on StandardError, wait: 10.seconds, attempts: 3
# Schedule for later
NotifyUserJob.set(wait: 5.minutes).perform_later(user)
🎉 Done!
Your Rails app now supports background jobs using ActiveJob and Sidekiq. Use it for emails, notifications, data exports, uploads, and more — without slowing down your UI.
💡 Examples
Example 1: Sending an email after signup
# app/jobs/notify_user_job.rb
class NotifyUserJob < ApplicationJob
queue_as :default
def perform(user)
UserMailer.welcome_email(user).deliver_now
end
end
# app/controllers/users_controller.rb
def create
@user = User.create(user_params)
NotifyUserJob.perform_later(@user)
end
📬 The welcome email is sent in the background after user creation.
Example 2: Retry a job on failure (e.g., external API timeout)
class SyncWithAPIJob < ApplicationJob
retry_on Net::OpenTimeout, wait: 10.seconds, attempts: 3
def perform(resource_id)
ExternalService.sync(resource_id)
end
end
🔁 The job will automatically retry if it hits a timeout error.
Example 3: Schedule a job to run in the future
# Remind user after 24 hours
RemindUserJob.set(wait: 24.hours).perform_later(user)
🕐 The job will be delayed and processed later by Sidekiq.
Example 4: Perform a job immediately (for testing)
ExportReportJob.perform_now(report_id)
⚠️ Only use perform_now
in test environments or non-production workflows.
Example 5: Use Sidekiq's native syntax (bypassing ActiveJob)
# app/workers/hard_worker.rb
class HardWorker
include Sidekiq::Worker
sidekiq_options retry: 5, queue: 'critical'
def perform(name, count)
puts "Working on #{name} with count #{count}"
end
end
# Triggering it
HardWorker.perform_async("Saad", 5)
🔥 Use this only if you need full Sidekiq features (more control, more speed).
Example 6: Monitoring with Sidekiq Web UI
# config/routes.rb
require 'sidekiq/web'
mount Sidekiq::Web => '/sidekiq'
📊 Visit /sidekiq
to monitor job queues, status, and retry logic.
🔁 Alternatives
- ✅ DelayedJob (DB-based, easier for small apps)
- ✅ Resque (Redis-based but slower than Sidekiq)
- ✅ GoodJob (PostgreSQL-based background processing)
❓ General Q&A
Q: Why should I use background jobs?
A: To keep your app responsive. For example, don’t make users wait for an email or a PDF generation — move that work to a background worker.
Q: What’s the role of Sidekiq here?
A: Sidekiq runs the jobs you enqueue through ActiveJob. It processes jobs in a separate thread pool using Redis as the backend.
🛠️ Technical Q&A
Q: How do I retry jobs in case of failure?
class MyJob < ApplicationJob
retry_on SomeAPI::TimeoutError, wait: 5.seconds, attempts: 3
end
Q: How to schedule jobs?
# app/jobs/remind_user_job.rb
RemindUserJob.set(wait: 2.hours).perform_later(user)
✅ Best Practices
- ✅ Use
perform_later
notperform_now
inside controllers - ✅ Always make jobs idempotent (safe to retry)
- ✅ Use Sidekiq web UI for monitoring
/sidekiq
- ✅ Set timeouts and retries for external API calls inside jobs
- ✅ Avoid passing complex objects to jobs (prefer IDs)
🌍 Real-world Scenario
In a SaaS platform, PDF invoices were taking ~6 seconds to generate, slowing down the dashboard. The team moved invoice generation to a background job using Sidekiq. Now users see a “Your invoice is being prepared…” message and get notified once it’s ready — without any delay in the UI.
Webhooks and API Callbacks
🧠 Detailed Explanation
A webhook is a way for one app to notify another app when something important happens. Instead of asking over and over again (polling), the service just pushes the data to your server instantly.
Think of it like this: "Hey Rails app, someone just paid! Here’s the info." — that’s a webhook from a payment service like Stripe or PayPal.
💬 Real-World Analogy
Imagine you place a food delivery order. You don't keep calling the restaurant to ask if it's done. Instead, they call you or send a notification when your order is out for delivery.
That’s exactly what a webhook does.
⚙️ How It Works in Rails
You expose a public URL (like /webhooks/stripe
) that third-party services can send HTTP requests to when something happens.
Here’s what typically happens:
- 1️⃣ A payment is completed in Stripe
- 2️⃣ Stripe sends a POST request to your endpoint
- 3️⃣ Your Rails controller reads the request and processes it (or sends it to a background job)
- 4️⃣ You return
head :ok
to say “Got it!”
🔐 Is It Safe?
Yes — as long as you verify the webhook’s authenticity. Most services send a secret token or signature in the headers. You can use that to ensure the request is real and hasn’t been tampered with.
Stripe::Webhook.construct_event(
payload,
signature,
webhook_secret
)
🧪 What About Testing?
Most webhook providers (like Stripe, GitHub, Shopify) give you tools or sandbox environments to test your integration. You can also use tools like:
- Webhook.site
- ngrok for tunneling to localhost
- Postman or curl for sending mock webhook events
📋 Summary
Webhooks are how other apps tell your Rails app that something important happened — instantly and automatically.
They’re used for payments, emails, version control, SMS, e-commerce, and many more events that happen outside of your app.
✅ With a secure endpoint, proper event handling, and background jobs, you can confidently integrate webhooks into any Rails project.
📦 Step-by-Step Implementation
🛣 Step 1: Define a route for the webhook
# config/routes.rb
post "/webhooks/stripe", to: "webhooks#stripe"
This creates an endpoint that Stripe (or any service) will POST to.
🎯 Step 2: Create a Webhooks controller
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def stripe
payload = request.body.read
sig_header = request.env['HTTP_STRIPE_SIGNATURE']
secret = Rails.application.credentials[:stripe][:webhook_secret]
begin
event = Stripe::Webhook.construct_event(payload, sig_header, secret)
rescue JSON::ParserError, Stripe::SignatureVerificationError
return head :bad_request
end
case event['type']
when 'invoice.payment_succeeded'
InvoiceSuccessJob.perform_later(event['data']['object'])
when 'payment_intent.failed'
PaymentFailureJob.perform_later(event['data']['object'])
end
head :ok
end
end
📦 Use background jobs to avoid long processing in the controller.
⚙️ Step 3: Create background jobs to process events
# app/jobs/invoice_success_job.rb
class InvoiceSuccessJob < ApplicationJob
queue_as :default
def perform(data)
user = User.find_by(stripe_customer_id: data["customer"])
user.update(status: "paid")
UserMailer.invoice_paid(user).deliver_later
end
end
🔒 Step 4: Secure your webhook endpoint
Always verify the webhook is from a trusted source:
- ✅ Stripe: Use
Stripe::Webhook.construct_event
- ✅ GitHub: Validate
X-Hub-Signature
header - ✅ Custom services: Use secret token in params or header
📋 Step 5: Add logging (optional but recommended)
Rails.logger.info "Received Stripe webhook: #{event['type']}"
✅ Helpful for debugging and auditing webhook activity.
🧪 Step 6: Test your webhook
You can simulate webhook requests using:
- 🔧
stripe listen
CLI tool - 🧪 Tools like webhook.site
- 🧪 Postman with a POST request and JSON body
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: ..." \
-d '{"type": "invoice.payment_succeeded", "data": {...}}'
🎉 Done!
You've now set up webhook support in Rails! Your app can now receive real-time updates from external services securely and efficiently.
💡 Examples
Example 1: Stripe Webhook Endpoint
# config/routes.rb
post "/webhooks/stripe", to: "webhooks#stripe"
# app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def stripe
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
secret = Rails.application.credentials[:stripe][:webhook_secret]
begin
event = Stripe::Webhook.construct_event(payload, sig_header, secret)
rescue JSON::ParserError, Stripe::SignatureVerificationError
return head :bad_request
end
case event["type"]
when "invoice.payment_succeeded"
InvoiceSuccessJob.perform_later(event["data"]["object"])
when "payment_intent.failed"
PaymentFailureJob.perform_later(event["data"]["object"])
end
head :ok
end
end
✅ Secure, fast, and scalable handling of Stripe events.
Example 2: GitHub Webhook Handling (Push Events)
# config/routes.rb
post "/webhooks/github", to: "webhooks#github"
# app/controllers/webhooks_controller.rb
def github
signature = request.headers["X-Hub-Signature"]
payload = request.body.read
secret = ENV["GITHUB_SECRET"]
return head :unauthorized unless valid_signature?(payload, signature, secret)
event = JSON.parse(payload)
RepositorySyncJob.perform_later(event)
head :ok
end
def valid_signature?(payload, signature, secret)
expected = "sha1=" + OpenSSL::HMAC.hexdigest("sha1", secret, payload)
Rack::Utils.secure_compare(expected, signature)
end
🔒 Verifies the HMAC SHA1 signature sent by GitHub.
Example 3: Custom Webhook for Order Status
# config/routes.rb
post "/webhooks/order_callback", to: "webhooks#order_callback"
def order_callback
data = JSON.parse(request.body.read)
order = Order.find_by(reference: data["order_id"])
if order
order.update(status: data["status"])
OrderNotificationJob.perform_later(order.id)
end
head :ok
end
📦 Common for courier or shipping services to notify order delivery status.
Example 4: Logging Webhook Payloads for Debugging
def webhook_logger
payload = request.body.read
Rails.logger.info("Webhook Payload: #{payload}")
head :ok
end
🪵 Very helpful during development and debugging stages.
Example 5: Retryable Background Job from Webhook
class InvoiceSuccessJob < ApplicationJob
retry_on Net::OpenTimeout, wait: 5.seconds, attempts: 3
def perform(invoice_data)
user = User.find_by(stripe_customer_id: invoice_data["customer"])
user.update(paid: true)
UserMailer.invoice_email(user).deliver_later
end
end
🔁 Automatically retries if there's a network error or email server issue.
🔁 Alternatives
- ✅ Polling (not real-time, increases load)
- ✅ Third-party background jobs like Zapier or Integromat
- ✅ Pub/Sub using Redis, Kafka, or external triggers
❓ General Q&A
Q: What is the difference between a webhook and an API?
A: A webhook pushes data to your app; an API requires you to pull data by making a request. Webhooks are event-driven.
Q: Do I need authentication for webhooks?
A: Yes! Most services include a secret signature header that you should verify to make sure the request is from them.
🛠️ Technical Q&A
Q: How do I verify a Stripe webhook signature?
# Using Stripe gem
event = Stripe::Webhook.construct_event(
request.body.read,
request.env['HTTP_STRIPE_SIGNATURE'],
Rails.application.credentials[:stripe][:webhook_secret]
)
Q: How do I retry a failed webhook?
A: Most services like Stripe and GitHub will retry failed webhooks automatically (3-5 times) with exponential backoff.
✅ Best Practices
- ✅ Always log incoming payloads (but sanitize sensitive info)
- ✅ Validate signatures or tokens
- ✅ Acknowledge receipt quickly using
head :ok
- ✅ Use background jobs (e.g. Sidekiq) to process the event
- ✅ Avoid raising exceptions inside webhook controller actions
🌍 Real-world Scenario
A Rails app selling online courses uses Stripe to handle payments. When a student successfully pays, Stripe sends a webhook to the app. The webhook controller receives the event and enqueues a job to mark the course as "unlocked" for that user, send a confirmation email, and update analytics — all without needing the student to wait.
Swagger API Docs (rswag)
🧠 Detailed Explanation
When you build an API in Rails, it's important to tell others how to use it — what endpoints you offer, what data they should send, and what they’ll get back. This is called API documentation.
💡 What is Swagger?
Swagger (now part of the OpenAPI standard) is a way to write this documentation in a structured format that tools can read. It allows developers to:
- 📘 See all available API endpoints
- 💬 Understand what data each endpoint expects
- 🧪 Try out API requests live in the browser
With Swagger, you create a helpful page (like /api-docs
) where frontend teams, mobile developers, or external partners can explore and test your API — without asking questions or guessing.
🔧 What is Rswag?
Rswag is a Ruby gem that makes Swagger easy to use in Rails. Instead of writing documentation separately, it lets you build your Swagger docs directly from your RSpec tests.
That means when you write a test for your API, it becomes documentation too — no extra work!
🛠 How It Works
Here’s the basic idea:
- ✅ You write RSpec tests for your API endpoints (like
GET /api/v1/users
) - ✅ Rswag reads your test file and turns it into Swagger documentation
- ✅ You run the tests, and Swagger docs are generated automatically
- ✅ You visit
/api-docs
in your browser and see the full docs — clickable and testable
🙌 Why It’s Useful
- 📣 Share API access with teams and clients easily
- ✅ Keep docs always in sync with your actual code
- 🛠 Reduce manual writing and human error
- 🔄 Stay up to date as your app evolves
📋 Summary
Swagger makes your API easy to understand and use.
Rswag helps Rails developers generate that Swagger documentation automatically, right from their tests.
It saves time, avoids mistakes, and gives you beautiful, interactive API docs at /api-docs
.
📦 Step-by-Step Implementation
🧩 Step 1: Add Rswag to your Gemfile
# Gemfile
group :development, :test do
gem 'rswag'
end
Then install:
bundle install
⚙️ Step 2: Run Rswag installer
rails g rswag:install
This generates:
spec/swagger_helper.rb
(main config)spec/integration
(your API test directory)- Swagger UI route:
/api-docs
🧪 Step 3: Write your first Swagger spec
Create a file:
# spec/integration/users_spec.rb
require 'swagger_helper'
RSpec.describe 'Users API', type: :request do
path '/api/v1/users' do
get 'Returns all users' do
tags 'Users'
produces 'application/json'
response '200', 'users listed' do
run_test!
end
end
end
end
💡 Rswag uses RSpec to build both your test and your Swagger doc!
🚀 Step 4: Run the spec to generate the docs
bundle exec rspec spec/integration
This creates Swagger JSON files in swagger/v1/swagger.yaml
.
🌐 Step 5: Access the Swagger UI
Start your Rails server and visit:
http://localhost:3000/api-docs
You’ll see the interactive Swagger UI with your endpoint documentation.
🔐 Step 6: Secure your Swagger docs in production (optional)
Add authentication if exposing API docs publicly:
# config/routes.rb
authenticate :admin_user do
mount Rswag::Ui::Engine => '/api-docs'
end
🎉 Done!
You now have a real-time Swagger documentation system that evolves with your tests.
Share /api-docs
with frontend teams or clients to explore your API with confidence!
💡 Examples
Example 1: Documenting a simple GET endpoint
# spec/integration/users_spec.rb
require 'swagger_helper'
RSpec.describe 'Users API', type: :request do
path '/api/v1/users' do
get 'Get all users' do
tags 'Users'
produces 'application/json'
response '200', 'successful' do
run_test!
end
end
end
end
✅ When you visit /api-docs
, you’ll see this as a clickable, testable GET endpoint.
Example 2: Documenting a POST endpoint with parameters
path '/api/v1/users' do
post 'Create a user' do
tags 'Users'
consumes 'application/json'
parameter name: :user, in: :body, schema: {
type: :object,
properties: {
name: { type: :string },
email: { type: :string }
},
required: ['name', 'email']
}
response '201', 'user created' do
let(:user) { { name: 'John Doe', email: 'john@example.com' } }
run_test!
end
response '422', 'invalid request' do
let(:user) { { name: '' } }
run_test!
end
end
end
💡 This creates a live form on Swagger UI to try out user creation.
Example 3: Using response schemas
response '200', 'user found' do
schema type: :object,
properties: {
id: { type: :integer },
name: { type: :string },
email: { type: :string }
},
required: ['id', 'name', 'email']
let(:id) { User.create(name: "John", email: "john@example.com").id }
run_test!
end
✅ This ensures your API response matches the expected structure.
Example 4: Securing endpoints using headers
parameter name: 'Authorization',
in: :header,
type: :string,
description: 'Access token'
response '200', 'authenticated' do
let(:Authorization) { "Bearer #{jwt_token}" }
run_test!
end
🔐 This documents token-based authentication for your APIs.
Example 5: Grouping endpoints with tags
get 'Show a user' do
tags 'Users'
produces 'application/json'
parameter name: :id, in: :path, type: :string
response '200', 'user found' do
let(:id) { User.create(name: 'Jane', email: 'jane@example.com').id }
run_test!
end
end
🏷️ Tagging keeps Swagger UI organized by resource (Users, Orders, etc.).
🔁 Alternatives
- ✅ Postman Collection Generator
- ✅ Apipie for Rails
- ✅ Manually writing OpenAPI YAML (less preferred)
❓ General Q&A
Q: What is the benefit of Swagger docs?
A: It provides clear, interactive API documentation for frontend developers and third parties to explore and test endpoints.
Q: Do I have to write Swagger JSON manually?
A: No — rswag builds it from RSpec tests, saving time and reducing human error.
🛠️ Technical Q&A
Q: Where does the Swagger UI live in development?
A: Visit /api-docs
after setup to view your interactive documentation.
Q: Can I protect Swagger docs in production?
A: Yes, use Devise or Basic Auth to restrict access.
✅ Best Practices
- ✅ Always write specs for every endpoint — it doubles as documentation
- ✅ Group APIs logically (auth, users, orders, etc.) using
tags
- ✅ Validate example responses with schema matching
- ✅ Document request and response params clearly
- ✅ Don’t expose Swagger in production without auth
🌍 Real-world Scenario
In a B2B SaaS platform, the API was used by multiple external vendors.
Using Rswag and Swagger UI, the team created an up-to-date API reference at /api-docs
with examples and schema validations.
This reduced onboarding time, minimized support tickets, and improved developer trust.
Gem for API Structure (like Grape)
🧠 Detailed Explanation
When you're building APIs in Rails, you might want more structure, better version control, and clean separation of logic — especially if your app serves mobile apps, frontend clients (like React or Vue), or other services.
This is where the Grape gem comes in. It's like a mini-framework for APIs that you can use inside your Rails app — making your API more organized, modular, and easier to manage over time.
🍇 What is Grape?
Grape is a Ruby gem that helps you write RESTful APIs easily. You can use it:
- 📦 Inside a Rails app
- 💡 With versioning like
/api/v1
- ✅ With validation built-in
- 🔗 With Swagger (via grape-swagger) for automatic documentation
Grape keeps your API code separate from your controllers, which makes it easier to maintain as your app grows.
📁 How does it work?
You create a special directory like app/api
, and inside it, you define different versions and endpoints.
For example, if you want to create a users API:
- You write a file
v1/base.rb
for version 1 - You define routes like
GET /api/v1/users
andPOST /api/v1/users
- You use built-in
params
to validate input (no need for strong params)
✨ Why use Grape instead of plain Rails API?
Rails (especially rails new myapp --api
) is great, but Grape offers extra structure:
- 📘 Versioning is built-in and clean
- ✅ Routes are simpler (no routes.rb clutter)
- 🚀 Built-in input validation and error handling
- 🧪 Easier to modularize and test
- 🔧 Mountable — can plug into any Rails app
📋 Summary
Grape helps you create cleaner, more powerful APIs in Ruby — especially when you want a clear separation between frontend and backend, or need versioned, scalable API endpoints.
It’s easy to add to any Rails app, and a perfect choice if you're building a public API, a mobile app backend, or just want better API organization.
📦 Step-by-Step Implementation
🧩 Step 1: Add the Grape gem
# Gemfile
gem 'grape'
Then run:
bundle install
📂 Step 2: Create API directory
Inside your app directory, create a new folder structure:
mkdir -p app/api/api/v1
touch app/api/api/base.rb
touch app/api/api/v1/base.rb
🧠 Step 3: Define your base API class
# app/api/api/base.rb
module API
class Base < Grape::API
prefix 'api'
format :json
mount API::V1::Base
end
end
This sets up a global prefix /api
and mounts versioned APIs.
🔧 Step 4: Define your versioned API endpoints
# app/api/api/v1/base.rb
module API
module V1
class Base < Grape::API
version 'v1', using: :path
format :json
resource :users do
desc 'Return a list of users'
get do
User.all
end
desc 'Create a new user'
params do
requires :name, type: String
requires :email, type: String
end
post do
User.create!({ name: params[:name], email: params[:email] })
end
end
end
end
end
🎯 This defines GET /api/v1/users
and POST /api/v1/users
with validations.
🛣 Step 5: Mount the API in your Rails routes
# config/routes.rb
mount API::Base => '/'
Now Grape handles all requests to /api/v1/*
.
✅ Step 6: Test your endpoints
You can now test using:
- 🌐 Postman or Insomnia
- 🧪 Curl:
curl http://localhost:3000/api/v1/users
📘 Step 7 (Optional): Add grape-swagger for live API docs
# Gemfile
gem 'grape-swagger'
Then mount it:
# app/api/api/base.rb
add_swagger_documentation(api_version: 'v1', mount_path: '/docs')
Visit /api/docs
for live Swagger-like documentation.
🎉 Done!
You've now structured your API with Grape inside Rails — clean, versioned, and extensible. This is great for scaling large apps, managing mobile/web clients, or creating microservices.
💡 Examples
Example 1: Simple GET endpoint
# app/api/api/v1/base.rb
module API
module V1
class Base < Grape::API
version 'v1', using: :path
format :json
resource :hello do
get do
{ message: 'Hello, API world!' }
end
end
end
end
end
✅ Access this with: /api/v1/hello
Example 2: Creating a user with input validation
resource :users do
desc 'Create a new user'
params do
requires :name, type: String, desc: 'User name'
requires :email, type: String, desc: 'User email'
end
post do
User.create!(name: params[:name], email: params[:email])
end
end
✅ This creates a user at /api/v1/users
and returns a 201 response.
Example 3: Mounting your API in Rails
# app/api/api/base.rb
module API
class Base < Grape::API
prefix 'api'
mount API::V1::Base
end
end
# config/routes.rb
mount API::Base => '/'
✅ All API versions and routes are now accessible under /api/*
Example 4: Error handling
rescue_from ActiveRecord::RecordNotFound do |e|
error_response(message: "Record not found: #{e.message}", status: 404)
end
❌ This will catch record errors and return a proper JSON 404 response.
Example 5: Versioned endpoint separation
If you later create api/v2/base.rb
, you can start fresh with:
module API
module V2
class Base < Grape::API
version 'v2', using: :path
format :json
get :ping do
{ version: 'v2', message: 'pong' }
end
end
end
end
🌱 Perfect for evolving APIs without breaking old clients.
🔁 Alternatives
- Rails API-only mode — use
rails new app_name --api
- Hanami::API — lightweight and fast Ruby API-only framework
- Rails + Namespace — using traditional Rails namespacing for API structure
❓ General Q&A
Q: Can I use Grape with Rails?
A: Yes! It can run inside your Rails app as a mounted engine or standalone. Perfect for microservices or isolated APIs.
Q: Does Grape support versioning?
A: Yes — Grape makes it very easy to manage versioned APIs using paths like /api/v1/...
🛠️ Technical Q&A
Q: Can I use strong parameters or ActiveModel validation in Grape?
A: Grape has its own params validation DSL. You can also integrate with ActiveModel if needed.
params do
requires :email, type: String
requires :name, type: String
end
✅ Best Practices
- ✅ Keep each versioned API in its own module folder
- ✅ Use Grape’s DSL to validate input early
- ✅ Combine with Swagger (via grape-swagger) for auto docs
- ✅ Use Grape Entities or serializers to structure output
- ✅ Mount API cleanly in
config/routes.rb
to stay Rails-friendly
🌍 Real-world Scenario
A Rails application serving both a web frontend and mobile apps used Grape to build a clean, versioned API layer. This separation allowed backend and mobile teams to work independently with predictable endpoints and better performance. Grape's versioning and input validation helped scale the system as more client apps were added.
Real-Time Feature in API (Rails)
🧠 Detailed Explanation
Normally, when you build an API, it works like this:
➡️ The client sends a request ➡️ the server responds with data.
But what if the server needs to send data to the client without waiting for a request? Like when a new chat message arrives, or an order status changes? That’s where real-time APIs come in.
💡 What does “real-time” mean?
It means the client gets updates immediately — without refreshing, reloading, or sending repeated requests.
Just like how WhatsApp shows “typing...” or how Uber updates the driver’s location instantly.
🚀 How do we make Rails APIs real-time?
Rails has a built-in tool called ActionCable which uses something called WebSockets. WebSockets keep a live connection open between the server and client.
So now:
- 🔌 Client connects to the server once
- 📡 Server can send data anytime (like an event)
- 🧠 You don’t have to reload or ask again
📦 What’s a real-time API example?
Imagine an order tracking app:
- 🚚 Order is created (status: “preparing”)
- ✅ Kitchen updates it to “ready”
- 📦 Delivery agent picks it up → status becomes “on the way”
The client app (React, mobile, etc.) sees each change instantly on the screen — because it’s connected via WebSocket.
🔐 Can clients use this?
Yes — clients (like JavaScript apps or mobile apps) can connect to your Rails server via WebSocket and receive updates.
They just need the right channel and ID (like order_42
).
🌍 Summary
Real-time features in APIs make your app feel alive ✨. They’re great for things like notifications, chat, live dashboards, or anything that changes fast.
Rails supports real-time updates using ActionCable, or you can use services like Pusher or Ably if you want less setup and better scaling.
📦 Step-by-Step Implementation
🧩 Step 1: Enable ActionCable in a Rails app
In a Rails app (even API-only), add this in config/routes.rb
:
# config/routes.rb
mount ActionCable.server => '/cable'
Make sure WebSocket server is allowed:
# config/environments/development.rb
config.action_cable.disable_request_forgery_protection = true
config.action_cable.allowed_request_origins = [ 'http://localhost:3000' ]
📡 Step 2: Create a Channel
# app/channels/order_channel.rb
class OrderChannel < ApplicationCable::Channel
def subscribed
stream_from "order_#{params[:order_id]}"
end
def unsubscribed
# Clean up if needed
end
end
This lets each client listen only to their order updates.
📨 Step 3: Trigger a broadcast
# app/controllers/orders_controller.rb
def update
order = Order.find(params[:id])
order.update!(status: params[:status])
ActionCable.server.broadcast("order_#{order.id}", {
id: order.id,
status: order.status,
updated_at: order.updated_at
})
head :ok
end
This pushes a message to everyone subscribed to order_#{id}
.
🧪 Step 4: Client-side - JavaScript or React (Third-party)
Clients can connect via WebSocket and receive real-time updates:
// app/javascript/channels/order_channel.js (Rails + JS)
import consumer from "./consumer"
consumer.subscriptions.create(
{ channel: "OrderChannel", order_id: 42 },
{
received(data) {
console.log("Order update received:", data)
// Update the UI or state here
}
}
)
Or plain WebSocket (mobile, React Native, Node.js):
// JavaScript example using browser WebSocket
const socket = new WebSocket("ws://localhost:3000/cable")
socket.onopen = () => {
socket.send(JSON.stringify({
command: "subscribe",
identifier: JSON.stringify({
channel: "OrderChannel",
order_id: 42
})
}))
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === "ping" || !data.message) return
console.log("🔴 Real-time data:", data.message)
}
🚀 Step 5: Going Production? Use Redis + Puma
In cable.yml
:
# config/cable.yml
production:
adapter: redis
url: redis://localhost:6379
And in Procfile
(if using Heroku or foreman):
web: bundle exec puma -C config/puma.rb
💼 Step 6: Using a third-party service like Pusher
Use the pusher
gem or JavaScript SDK:
# Gemfile
gem 'pusher'
# Broadcast from Rails controller
Pusher.trigger("order_#{order.id}", "status_updated", {
status: order.status
})
// JS Client
const pusher = new Pusher('APP_KEY', {
cluster: 'APP_CLUSTER'
})
const channel = pusher.subscribe('order_42')
channel.bind('status_updated', function(data) {
console.log("Live update:", data)
})
✅ Pusher handles scaling, reconnections, and pub/sub for you.
🎉 Done!
You now have a real-time setup in Rails! Whether you use built-in ActionCable or external tools like Pusher or Ably, your API can now push updates as events happen — no need to refresh or poll.
💡 Examples
Example 1: Creating a Channel for Order Updates
# app/channels/order_channel.rb
class OrderChannel < ApplicationCable::Channel
def subscribed
stream_from "order_#{params[:order_id]}"
end
end
This lets each client subscribe to order_42
, for example, to get real-time updates for their order.
Example 2: Broadcasting an Update from the Controller
# app/controllers/orders_controller.rb
def update
order = Order.find(params[:id])
order.update(status: params[:status])
ActionCable.server.broadcast("order_#{order.id}", {
id: order.id,
status: order.status
})
render json: { message: "Updated and broadcasted" }
end
✅ Now when the order status changes, all subscribers get the update instantly.
Example 3: JavaScript Client (Browser or React)
const socket = new WebSocket("ws://localhost:3000/cable")
socket.onopen = () => {
socket.send(JSON.stringify({
command: "subscribe",
identifier: JSON.stringify({
channel: "OrderChannel",
order_id: 42
})
}))
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === "ping" || !data.message) return
console.log("📦 Live Order Update:", data.message)
}
🔁 This keeps the client "listening" and automatically shows updates on the screen.
Example 4: Rails Route Configuration
# config/routes.rb
mount ActionCable.server => "/cable"
📡 This exposes the WebSocket endpoint at /cable
.
Example 5: Using Pusher as a 3rd-party alternative
# Gemfile
gem 'pusher'
# Controller
Pusher.trigger("order_42", "status_updated", {
id: 42,
status: "delivered"
})
// JS Client using Pusher
const pusher = new Pusher("your-key", { cluster: "your-cluster" })
const channel = pusher.subscribe("order_42")
channel.bind("status_updated", function(data) {
console.log("🚀 From Pusher:", data)
})
✅ Pusher handles the WebSocket server for you — great for production and scaling fast.
🔁 Alternatives
- ✅ Server-Sent Events (SSE) – lightweight push notifications via HTTP
- ✅ Pusher / Ably – managed WebSocket services
- ✅ Firebase – built-in realtime database syncing
❓ General Questions & Answers
Q1: What does “real-time” mean in an API?
A: Real-time means the server can push data to the client immediately without the client needing to ask again. It makes things like notifications, chat messages, and live updates possible.
Q2: Can I build real-time APIs using Rails?
A: Yes. Rails has a built-in feature called ActionCable
that adds WebSocket support to your app. It allows two-way communication between the server and the client.
Q3: Do real-time APIs replace REST APIs?
A: No. REST APIs are still used for most operations (CRUD). Real-time APIs are used in special cases — like sending updates, notifications, or syncing live data without refresh.
Q4: What are examples of real-time features?
- 💬 Live chat apps
- 📦 Order status tracking
- 📈 Live dashboards and graphs
- 🔔 Instant notifications (like “new message” or “order shipped”)
Q5: How do I connect to a real-time API from the frontend?
A: You use WebSocket
clients (in JavaScript, React, mobile apps, etc.) to connect to a channel (like order_42
). Once connected, any updates from the server will be received automatically.
Q6: What if I don’t want to manage WebSocket servers?
A: You can use a hosted service like Pusher, Ably, or Firebase. These handle connections, reconnections, and scaling for you — ideal for mobile apps or production use.
Q7: Will it work with React or a mobile app?
A: Yes! You can use ActionCable in React apps or any app that supports WebSocket clients. Just connect to /cable
and subscribe to the right channel.
🛠️ Technical Q&A
Q1: What is ActionCable in Rails?
A: ActionCable is a built-in WebSocket framework in Rails. It allows Rails servers to handle real-time connections and push data to connected clients without polling.
Q2: What is the WebSocket endpoint in a Rails app?
A: After mounting ActionCable in routes.rb
, the endpoint is usually /cable
. This is where WebSocket clients connect to start listening for updates.
# config/routes.rb
mount ActionCable.server => '/cable'
Q3: How do I authorize users in ActionCable?
A: In ApplicationCable::Connection
, you can identify the current user using session or token-based logic:
# app/channels/application_cable/connection.rb
identified_by :current_user
def connect
self.current_user = find_verified_user
end
def find_verified_user
User.find_by(token: request.params[:token]) || reject_unauthorized_connection
end
Q4: How do I test real-time features?
A: You can test using browser WebSocket clients (like in Chrome DevTools), frontend frameworks (React, Vue, etc.), or even with libraries like `faye-websocket`, Postman, or custom scripts.
Q5: What happens when I deploy ActionCable in production?
A: You need to run ActionCable on a separate process or thread-safe server (like Puma), and configure Redis as the pub/sub adapter:
# config/cable.yml
production:
adapter: redis
url: redis://localhost:6379/1
This ensures all ActionCable connections share the same message bus.
Q6: What is the difference between broadcasting and streaming?
A: stream_from
subscribes a client to a named channel. broadcast
sends data to that channel. When something is broadcasted, all stream subscribers get it immediately.
Q7: Can I use ActionCable in an API-only Rails app?
A: Yes! But you’ll need to add back some middleware (like cookies/sessions) if you're using session-based auth. Otherwise, token-based auth in headers or query params is best for APIs.
Q8: What are some alternatives to ActionCable?
- 📡 Pusher – real-time push service (easy setup, paid)
- 📡 Ably – similar to Pusher with more features
- 📡 AnyCable – allows using ActionCable with Go for better performance
- 📡 Server-Sent Events (SSE) – simpler than WebSocket, one-way only
✅ Best Practices for Real-Time APIs
1. Always use channel-specific streams
Don’t broadcast to all users — stream to only relevant clients.
stream_from "order_#{params[:order_id]}"
ActionCable.server.broadcast("order_42", { status: "shipped" })
✅ This avoids noise and keeps updates precise.
2. Secure your connections
Identify and authenticate users inside ApplicationCable::Connection
to prevent unauthorized subscriptions.
def connect
self.current_user = find_verified_user
end
❌ Never allow anonymous access to sensitive channels.
3. Use Redis in production
Use redis
as the backend to handle pub/sub across multiple instances (especially in Heroku, EC2, Kubernetes).
# config/cable.yml
production:
adapter: redis
url: redis://localhost:6379/1
4. Cleanly name your channels
Use scoped naming: user_#{id}
, chat_#{room_id}
, order_#{id}
to keep them logical and manageable.
5. Don’t push too often — debounce or throttle
For fast-changing data (like location), avoid sending updates every second. Use timers or diffs.
6. Combine with background jobs
Broadcast from Sidekiq or ActiveJob when long tasks finish (e.g. email sent, image processed).
class NotifyUserJob < ApplicationJob
def perform(user)
ActionCable.server.broadcast("user_#{user.id}", { message: "Done!" })
end
end
7. Test with WebSocket clients early
Use browser DevTools, Postman, or custom JS/React clients to verify your channel behavior during development.
8. Document your channels like APIs
Let frontend teams know how to subscribe, what messages they'll receive, and expected structure.
9. Fall back to polling if needed
In case WebSocket fails (e.g. on old browsers or networks), you can fallback to API polling every few seconds.
10. Monitor connections in production
Keep logs of subscriptions, disconnections, errors. Use Redis metrics or integrate with tools like AnyCable Monitor.
🌍 Real-world Scenario
Imagine you're building a food delivery app like Uber Eats or Foodpanda. When a customer places an order, they want to know what's happening in real time:
- 🥡 Order placed → “Waiting for restaurant”
- 👨🍳 Restaurant accepted → “Being prepared”
- 📦 Out for delivery → “Rider on the way”
- ✅ Delivered → “Order complete”
Instead of making the user refresh the screen or poll the API every 10 seconds, you can push updates in real time using ActionCable or Pusher.
🛠 How It Works:
- Each customer connects to
/cable
and subscribes toorder_#{order_id}
- The backend broadcasts every time the order status changes
- The frontend listens and updates the UI instantly (without reload)
// JS: Subscribe to order updates
const socket = new WebSocket("ws://yourdomain.com/cable")
socket.onopen = () => {
socket.send(JSON.stringify({
command: "subscribe",
identifier: JSON.stringify({
channel: "OrderChannel",
order_id: 123
})
}))
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.message) {
updateOrderStatusUI(data.message.status)
}
}
🚀 Benefits:
- 📱 Better user experience (live updates, no refresh)
- 📉 Reduces server load (no polling)
- 📡 Scalable for hundreds of users at once using Redis or Pusher
- 🧠 Helps delivery riders, admins, and customers stay in sync
✨ Bonus Use Cases:
- 💬 Chat features (e.g. between rider and customer)
- 🔔 Notifications (e.g. “Your food is here!”)
- 📈 Live dashboards (e.g. admin panel with all orders updating in real-time)
Very good https://lc.cx/xjXBQT
Awesome https://lc.cx/xjXBQT
Very good https://lc.cx/xjXBQT
Very good https://lc.cx/xjXBQT
Very good https://lc.cx/xjXBQT
Awesome https://lc.cx/xjXBQT