Rails on Rails
Rails gives you a structure to build on, but it doesn’t enforce how you use it. Is a turbo frame update or an HTML redirect the right response? Either works. How do you scope the data? How do you filter it? There are a few completely valid answers to each. That’s the problem. Once you have half the controllers doing one thing and the other half doing another, your product starts feeling inconsistent. Without shared abstractions there isn’t a consistent structure to reach for. By looking at one example, you wouldn’t know what to expect from the next.
If you’ve worked with a blueprint that tackles the boring pieces for you before, the absence of one is immediately noticeable. It’s in the boring bits where inconsistency creeps in the easiest. The instinct is to write a guide and enforce patterns in review whilst hoping that’s enough. The problem with relying on discipline is that it doesn’t scale. Abstractions are how you encode the right decisions and let the consistency happen by default, not by intervention.
Three layers carry most of that weight, how data flows in (scoping, filtering, ordering), how views compose against a design system, and how controllers respond after a save. Each is a place where small inconsistencies compound, and they’re the examples we’ll look at here.
The shape of the thing
Before drilling into the pieces, here’s the full concern at the top level1.
module CRUDResource
extend ActiveSupport::Concern
include Configuration
include Renders
include DataAccess
include LifecycleHooks
include Authorization
include Routes
include SaveAndRender
include ::ResponseHandling
end
Think of this as a map, or contents, rather than a definition. This article covers DataAccess,
LifecycleHooks and ResponseHandling, the data pipeline and the response layer. The
configuration DSL that sits between them is for part two.
The actions themselves aren’t in that list. They’re opt-in modules a controller includes
for the verbs it actually routes, either one at a time or CRUDResource::Actions::All for
the full RESTful set. A controller with no destroy route shouldn’t inherit a destroy action.
ResponseHandling sits outside the CRUDResource namespace deliberately. It’s useful
enough on its own that controllers outside this pattern reach for it too.
Start with the data pipeline
Rails CRUD controllers look alike because they are alike. Index, show, create, update, destroy. We’re doing the same dance with a different model. That repetition isn’t the problem. The problem is that the decisions worth reading (scoping, filtering, ordering, and pagination) get buried inside it.
Rails gives us the ‘resource’ and ‘collection’ terminology and RESTful routing conventions. Latch onto that and pull the data pipeline into a concern and every controller inherits it.
module CRUDResource
module DataAccess
extend ActiveSupport::Concern
private
def collection
@collection ||= begin
base = policy_scope(self.class.resource_class)
scope = resource_config.base_scope
scope ? instance_exec(base, &scope) : base
end
end
def paginated_collection
ordered = apply_ordering(apply_filtering(scoped_collection))
return ordered if resource_config.paginated == false
ordered.page(params[:page]).per(per_page)
end
def resource
@resource ||= collection.find_by!(id_field => params[:id])
end
end
end
policy_scope ensures Pundit scoping is always applied. There’s no version of this concern
that forgets authorisation. scoped_collection applies the named scope from the query param.
apply_filtering runs Ransack2. apply_ordering respects the configured sort. These
run in a defined order, once, and every controller including this concern gets them.
The lifecycle hooks are what make the concern extensible, and they get their own module so the data pipeline stays about data.
module CRUDResource
module LifecycleHooks
private
def before_create(resource) = before_persist(resource)
def before_update(resource) = before_persist(resource)
def before_persist(resource) = resource
def before_destroy(resource) = resource
def after_create; end
def after_update; end
end
end
before_create, before_update, and the shared before_persist give every controller clean
extension points without overriding core methods. Need to set an attribute before creation?
def before_create(resource)
resource.event = Current.event
resource
end
That’s it. No overriding create, no copying the full method body, no leaving the next developer wondering which controller’s version to use as their reference. This is the template method pattern3, the concern defines the algorithm, the hooks are the extension points. Using it correctly is the path of least resistance.
Here’s what a complete controller using the module looks like.
class GuestsController < ApplicationController
include CRUDResource
include CRUDResource::Actions::All # [index, show, new, create, edit, update, destroy]
crud_resource Participant, id_field: :uuid, order_by: :name
# Named scopes become tab-like filters on the index page,
# driven by a query param and applied to the collection automatically.
scope :confirmed, -> (collection) { collection.attending_event(Current.event) }
scope :awaiting, -> (collection) { collection.pending_event(Current.event) }
scope :declined, -> (collection) { collection.declined_event(Current.event) }
# These could also easily be inferred by naming conventions
renders(
index: -> { Guest::IndexComponent.new(collection) },
show: -> { Guest::ShowComponent.new(resource) },
form: -> { Guest::FormComponent.new(resource) }
)
private
def before_create(resource)
resource.event = Current.event
resource
end
def resource_params
params.require(:participant).permit(
:first_name, :last_name, :email, :mobile, :allowed_plus_ones, #...
)
end
end
That’s a full CRUD resource. Scoping, filtering, ordering, authorisation, and lifecycle hooks are all handled. The only code specific to guests is the configuration at the top, the one hook that stamps the event on creation and the permitted params for the resource. Everything else is inherited.
It’s worth noting that renders is only necessary because we’re using ViewComponents. With standard partials, Rails’ conventional view lookup would resolve guests/index, guests/show, and guests/_form automatically and this configuration wouldn’t be needed at all.
Bridge the gap between data and design system
With the data pipeline extracted, the next pain point is the view layer. Most index pages are a table and most show pages are a panel with fields. The instinct is to share partials, but anyone who’s maintained a mature Rails app knows where that leads.
LLMs make this worse, not better. They can generate partials fast, but each generation is slightly different. Without a shared structure to compose against, the variance compounds faster than it would by hand.
The better solution is resource components that compose design system building blocks. I’ve used this approach with JSX in a previous role and the clarity it brings is real. ViewComponent (or Phlex) brings the same structure to Rails.
Here’s the template for Guest::IndexComponent, a resource-specific ViewComponent whose job is to compose the generic table component with guest-specific columns.
<%= render(UI.table(title: "Guests", sortable: true)) do |table| %>
<% table.with_empty_state(title: "No guests yet") %>
<% table.with_action(href: new_app_guest_path) { "New Guest" } %>
<% table.with_header(label: "Name", sortable: true, column: "first_name") %>
<% table.with_header(label: "Email", sortable: true, column: "email") %>
<% table.with_header(label: "RSVP Status") %>
<% guests.each do |guest| %>
<% table.add_row(url: app_guest_path(guest)) do |row| %>
<% row.add_cell { |cell| cell.with_content(guest.full_name) } %>
<% row.add_cell { |cell| cell.with_content(guest.email) } %>
<% row.add_cell(type: :status) { |cell| cell.with_content(guest.rsvp_status) } %>
<% end %>
<% end %>
<% end %>
The table component doesn’t know what a guest or a vendor is. It knows actions, headers, rows, and how to render cell content based on its type (:string, :status, :currency). Those decisions are made once and every resource inherits them. Similar components handle show and form pages the same way.
The boundary is explicit and the dependency is declared at the call site. There’s no hunting for which instance variable a partial expects, no conditional that quietly handles a resource this partial wasn’t originally written for. The component is self-contained and the composition is obvious.
More signal, less noise. Faster to code, faster to review, and clearer in intent.
Unify how controllers respond
The data flows through the concern and views are composed from shared components. What’s left is how controllers respond after a save.
Without a shared handler, this is where consistency quietly falls apart. A junior shows a flash message on validation failure instead of re-rendering the form with errors in place. Another controller redirects on success instead of replacing a turbo frame. None of these are wrong enough to catch in review. They’re just different, and the differences compound.
Abstracting the response handling prevents the drift in styles. save_and_render wraps the save attempt, when it’s a success, it always responds in a consistent manner. On failure it re-renders the form component with errors in place, form_component is resolved from renders, the same configuration that declares index and show components. The controller never makes that decision itself, which is the point. The right behaviour for both success and failures is encoded once and inherited everywhere.
def save_and_render(resource, component:, message: nil, replace_target: nil)
if resource.save
yield if block_given?
render_for(
resource, component: component,
message: message, replace_target: replace_target
)
else
render_errors(
resource, component: form_component,
replace_target: replace_target
)
end
end
The success path calls render_for, which handles format negotiation once for every controller.
def render_for(object, component:, message: nil, replace_target: nil)
respond_to do |format|
format.turbo_stream do
streams = []
streams << turbo_stream.replace(replace_target, component) if replace_target
streams << flash_stream(message) if message
render turbo_stream: streams
end
format.html { render component }
format.json { render json: object }
end
end
With the response handling in place, the CRUD actions collapse.
def create
@resource = before_create(build_resource)
@success_message ||= "#{resource_class.model_name.human} has been added."
save_and_render(@resource, component: show_component, ...) { after_create }
end
def update
@resource = before_update(update_resource)
@success_message ||= "#{resource_class.model_name.human} has been updated."
save_and_render(@resource, component: show_component, ...) { after_update }
end
The hook points are clear and the defaults are sensible. ||= lets a before_action or hook override the message earlier in the flow without touching the action itself, so that overriding these and calling super is always an option.
When the pattern gets pressure-tested
The guests controller fits the concern cleanly but not every resource will. The messages controller shows how you can use what works and override only what doesn’t.
class MessagesController < ApplicationController
include CRUDResource
include CRUDResource::Actions::All
crud_resource MassCommunication
renders(
index: -> {
Message::IndexComponent.new(
message_flow: message_flow,
mass_communications: collection
)
},
show: -> { Message::ShowComponent.new(mass_communication: resource) },
form: -> {
Message::FormComponent.new(
message_flow: message_flow,
step_key: params[:step],
mass_communication: resource
)
}
)
def resend
resource.send!
render_for(
resource, component: show_component,
message: "Message resent to #{resource.recipient_count} guests"
)
end
private
def build_resource
step = message_flow.resolve_step(params[:step])
message_flow.build_mass_communication_for_step(step)
end
def after_create
resource.send!
end
def message_flow
@message_flow ||= MessageFlow.new(Current.event)
end
def resource_params
params.require(:mass_communication).permit(
:owner_id, :owner_type, :dynamic_group, :kind, :body, :subject, :template
)
end
end
This controller needs more complex components that take a message_flow. It overrides build_resource because a new message is constructed through a step-based flow, not a simple new call. after_create triggers a side effect of sending the message. The resend action isn’t part of the RESTful set at all, but it still uses render_for from the response handler, so it gets the same turbo stream, HTML, and JSON rendering for free without reimplementing any of it.
None of that required rewriting the pipeline. build_resource and after_create are the same hooks available to every controller, used here for heavier lifting. Custom actions like resend reach into the shared toolbox without reimplementing anything. The concern absorbs the complexity without the controller having to reimplement the parts that still apply: scoping, authorisation, response handling, error rendering.
That’s the test of a good abstraction. It shouldn’t only work for the easy cases but make the hard cases manageable without asking you to opt out of everything to handle a few differences.
The costs
The concern earns its keep when the work it absorbs outweighs what it adds in indirection. Indirection is a real cost. A developer debugging a scoping issue has to understand DataAccess before they can find where to look. A new team member has to learn the abstraction before they can contribute comfortably. Neither of those is free.
There’s also a threshold worth naming. The messages controller overrides two hooks and one method, the concern still does most of the work. If a controller is overriding five things and the shared behaviour is down to response handling, you’re probably better served writing it plainly and only including ResponseHandling. Once you start fighting the abstraction, bail on it, fall back to writing a controller from scratch and include what makes sense if anything at all. You don’t need to shoehorn everything into this one pattern. It’s a default, not an absolute.
Build this when you have more than a handful of CRUD resources and a design system to compose against. Don’t leave it to a massive refactor on a mature product.
It’s worth noting that some of the cost is mitigated by naming conventions that fit the Rails framework, the hooks should still feel natural to a Rails developer thanks to the conventions you get for free with Rails.
A reasonable place to stop
These three pieces work together. A controller concern handling data, resource components composing a design system, and a response handler unifying rendering.
A new resource has a clear pattern. Create a controller, include the concern, build its components, wire them in. A change to how tables render benefits every resource at once. A new developer opens a controller and knows what to expect before they’ve read a line.
The discipline from here is resisting the urge to keep growing the base. Every edge case that gets folded into the concern makes it harder to understand for everyone else. The hooks exist so that individual controllers can handle their own complexity. If a new requirement doesn’t clearly belong in the shared layer, it belongs in the controller. Be deliberate about what earns a place in the abstraction and let the hooks absorb the rest.
That last part is the thing that’s hard to articulate until you’ve felt the alternative. When the blueprint is there and working, it feels familiar. You can get straight to the substance once you know the pattern. You’re not fighting the noise. It’s all signal.
A next step
In a follow up part two, the renders lambdas, permitted params, and component wiring collapse into a single declarative adapter class. It delivers the power of frameworks like ActiveAdmin without sacrificing the ability to customise. That’s all for next time, but here’s what a GuestAdapter looks like in practice:
class GuestAdapter < ResourceAdapter
param_key :participant
permit :first_name, :last_name, :email, :mobile, :allowed_plus_ones
index do
title 'Guests'
icon :users
row_url { |guest| app_guest_path(guest.uuid) }
column :full_name, header: 'Name', sortable: true, type: :header
column :email, sortable: true
end
show do
title(&:full_name)
field :email, icon: :email
field :mobile, icon: :phone
field(:tags, render: :badges) { |guest| guest.groups.map(&:name) }
end
form do
inputs 'Personal Information' do
input :first_name
input :last_name
input :email
input :mobile
end
end
end
Footnotes
-
The examples here are from a side project but the patterns come from experience in larger organisations. I owe a debt to Paul Jones who thought deeply about resource abstraction, and to ActiveAdmin’s resource controller which follows much of this approach. ↩