class ProjectPresenter
def initialize(project, include_tasks: true)
@project = project
@include_tasks = include_tasks
end
def as_json(*)
payload = {
id: @project.id,
name: @project.name,
archived: @project.archived?,
owner: UserPresenter.new(@project.owner).as_json,
task_count: @project.tasks.size,
updated_at: @project.updated_at.iso8601
}
payload[:tasks] = presented_tasks if @include_tasks
payload
end
private
def presented_tasks
@project.tasks.map { |task| TaskPresenter.new(task).as_json }
end
end
class TaskPresenter
def initialize(task)
@task = task
end
def as_json(*)
{
id: @task.id,
title: @task.title,
status: status,
due_on: @task.due_on&.iso8601,
due_label: due_label,
overdue: overdue?
}
end
def status
@task.completed_at.present? ? "done" : "open"
end
def overdue?
return false if @task.due_on.nil? || @task.completed_at.present?
@task.due_on < Date.current
end
private
def due_label
return "No due date" if @task.due_on.nil?
return "Overdue" if overdue?
"Due #{@task.due_on.strftime('%b %-d')}"
end
end
class ProjectsController < ApplicationController
def index
projects = current_user.projects
.includes(:owner, :tasks)
.order(updated_at: :desc)
payload = projects.map do |project|
ProjectPresenter.new(project, include_tasks: false).as_json
end
render json: { projects: payload }
end
def show
project = current_user.projects
.includes(:owner, :tasks)
.find(params[:id])
render json: { project: ProjectPresenter.new(project).as_json }
rescue ActiveRecord::RecordNotFound
render json: { error: "Project not found" }, status: :not_found
end
end
This snippet shows how a Rails API endpoint can serialize a nested resource graph using plain Ruby presenter objects instead of reaching for a heavier serialization gem. The pattern keeps view logic out of both the model and the controller, so the model stays focused on persistence and business rules while the presenter owns the shape of the JSON payload.
In ProjectPresenter, the object wraps a single Project and exposes an as_json method that returns a plain hash. The important design choice is that nested associations are delegated to their own presenters: tasks maps each record through TaskPresenter, and owner is wrapped in a UserPresenter. This keeps each presenter responsible for exactly one resource type, so the composition mirrors the object graph. The include_tasks: keyword lets a caller opt out of the expensive nested collection, which is how a list endpoint can stay lightweight while a detail endpoint returns everything.
TaskPresenter demonstrates why presenters are more than a hash literal: due_label derives a human string from due_on, and status normalizes a raw enum-ish column into an API-friendly value. These are presentation concerns that would pollute the model if placed there, and would be duplicated across actions if placed in the controller. Because each presenter is a normal Ruby object, methods like overdue? are trivial to unit test in isolation without touching the database or the HTTP layer.
In ProjectsController, the actions stay thin: index builds a collection with includes(:owner, :tasks) to avoid N+1 queries, then maps each record through a presenter with include_tasks: false. The show action returns the full nested payload. Passing the include_tasks flag down is what makes the same presenter reusable across both actions.
The main trade-off is that presenters are manual — there is no automatic attribute reflection, so new fields must be added by hand. In exchange the payload is explicit, versioning is easy, and eager loading is controlled at the call site rather than hidden inside a serializer DSL. This approach fits teams that want predictable JSON and readable, testable view objects over configuration-driven magic.
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.