class PostsController < ApplicationController
def index
posts = Post.for_feed.page(params[:page]).per(25)
render json: {
data: posts.map { |post| PostSerializer.new(post).as_json },
meta: { page: posts.current_page, total: posts.total_count }
}
end
def show
post = Post.for_feed.find(params[:id])
render json: { data: PostSerializer.new(post).as_json }
end
end
class Post < ApplicationRecord
belongs_to :author, class_name: "User"
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil) }
def self.for_feed
published
.includes(:author, comments: :author)
.order(published_at: :desc)
.strict_loading
end
end
class Comment < ApplicationRecord
belongs_to :post, counter_cache: true
belongs_to :author, class_name: "User"
end
class PostSerializer
def initialize(post)
@post = post
end
def as_json
{
id: @post.id,
title: @post.title,
published_at: @post.published_at&.iso8601,
author: author_json,
comment_count: comment_count,
comments: @post.comments.map { |c| CommentSerializer.new(c).as_json }
}
end
private
def comment_count
# counter_cache column when present, else count in memory (no query)
@post.comments_count || @post.comments.size
end
def author_json
{ id: @post.author.id, name: @post.author.name }
end
end
class CommentSerializer
def initialize(comment)
@comment = comment
end
def as_json
{
id: @comment.id,
body: @comment.body,
author: { id: @comment.author.id, name: @comment.author.name }
}
end
end
This snippet shows how to build a JSON API endpoint that is provably free of N+1 queries by pairing an explicit preload contract with serializers that only touch already-loaded associations. The core idea is that serialization should never trigger a database query: every association a serializer reads must be loaded up front, so the number of queries stays constant regardless of how many records are rendered.
In PostsController, the index action delegates loading to a scope named for_feed rather than inlining includes. This keeps the preload contract in one place and next to the model it belongs to. The controller stays thin and simply hands the relation to PostSerializer, which is where a naive implementation would silently fire one query per post for author, per post for comments, and per comment for its author.
In Post model, for_feed uses includes(:author, comments: :author) to eager-load the full object graph the serializer needs, then strict_loading! is the safety net. strict_loading makes ActiveRecord raise ActiveRecord::StrictLoadingViolationError the moment any un-preloaded association is accessed, turning a hidden performance bug into a loud test failure. The counter_cache on Comment means comments_count is a real column, so rendering a count never loads the comment rows at all.
In PostSerializer, every method reads from associations that for_feed already loaded. comment_count prefers the cached column via comments_count and only falls back to size on an already-materialized collection, avoiding a COUNT(*) round trip. Nested comments are mapped with CommentSerializer, which itself only reads comment.author, kept warm by the comments: :author preload.
The trade-off is discipline: the serializer and the scope are tightly coupled, and adding a new field that reads a fresh association requires updating for_feed too. That coupling is intentional — strict_loading guarantees the two stay in sync, because forgetting the preload raises instead of quietly degrading. This pattern is worth reaching for on hot list endpoints where render fan-out is large and predictable, and it pairs well with a request-level query-count assertion in tests to lock the behavior in.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
Share this code
Here's the card — post it anywhere.