class OrderPresenter < SimpleDelegator
attr_reader :current_user
def initialize(order, current_user:)
super(order)
@current_user = current_user
end
def self.wrap(orders, current_user:)
orders.map { |o| new(o, current_user: current_user) }
end
def order
__getobj__
end
def status_label
status.to_s.humanize
end
def total_formatted
format("$%.2f", total_cents / 100.0)
end
def can_cancel?
return false if current_user.nil?
pending? && (current_user.admin? || current_user.id == customer_id)
end
end
class OrderSerializer
include JSONAPI::Serializer
set_type :order
set_id :id
attributes :number, :status_label, :total_formatted, :placed_at
attribute :cancelable do |presenter|
presenter.can_cancel?
end
attribute :total do |presenter|
presenter.total_cents
end
belongs_to :customer
has_many :line_items, serializer: LineItemSerializer
end
module Api
module V1
class OrdersController < Api::BaseController
def index
orders = current_user.visible_orders
.includes(:customer, :line_items)
.page(params[:page])
render json: OrderSerializer.new(
present(orders),
include: requested_includes,
meta: { total: orders.total_count }
).serializable_hash
end
def show
order = current_user.visible_orders.find(params[:id])
render json: OrderSerializer.new(
present(order),
include: requested_includes
).serializable_hash
end
private
def present(records)
if records.respond_to?(:map)
OrderPresenter.wrap(records, current_user: current_user)
else
OrderPresenter.new(records, current_user: current_user)
end
end
def requested_includes
(params[:include] || "").split(",").map(&:strip).reject(&:empty?)
end
end
end
end
This snippet shows how to keep controllers thin and JSON output stable by combining two complementary patterns: a presenter (view model) that computes derived, request-aware fields, and a fast_jsonapi-style serializer that shapes the wire format. The two live at different layers — the presenter owns what a record looks like for a given viewer, and the serializer owns how that gets encoded into a JSON:API document — so neither leaks concerns into the controller.
In OrderPresenter, an ActiveRecord Order is wrapped in a plain Ruby object built with SimpleDelegator. Delegating means the presenter transparently exposes the model's own attributes while adding presentation-only methods like status_label, total_formatted, and can_cancel?. It also carries the current_user so authorization-sensitive fields (can_cancel?) can depend on the viewer without the model reaching into request state. This is the key trade-off of the presenter pattern: a little indirection buys a place to put formatting and policy logic that would otherwise pollute models or views.
In OrderSerializer, the JSONAPI::Serializer DSL declares the resource type, its attributes, and relationships. Because the serializer is fed presenter instances, attribute :status_label and attribute :total resolve against presenter methods rather than raw columns, so the JSON:API contract stays decoupled from the database schema. The belongs_to :customer line emits a proper relationship object with resource linkage, which clients can use with include.
In Api::V1::OrdersController, the index and show actions wrap records via a private present helper before serialization. Passing params: { current_user: current_user } threads request context into the serializer so it can conditionally include fields, and include: lets callers request compound documents. Wrapping collections happens in memory, so for large result sets pagination (via page) matters to avoid instantiating thousands of presenters.
The payoff is a stable, testable boundary: the serializer is a pure function of presenters, presenters are cheap to unit test without HTTP, and the controller does almost nothing. When output needs to change, edits stay local — formatting in the presenter, structure in the serializer — instead of rippling through views or model callbacks.
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
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
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
Share this code
Here's the card — post it anywhere.