plugins/lisa-rails-cursor/skills/action-controller-best-practices/SKILL.md
Build or Refactor large Rails controller files into clean, maintainable code. Use when a controller action exceeds ~10 lines, a controller has custom non-RESTful actions, or when the user asks to refactor, slim down, clean up, or organize a Rails controller. Applies patterns: service objects, query objects, form objects, controller concerns, presenters/decorators, and RESTful resource extraction.
npx skillsauth add codyswanngt/lisa action-controller-best-practicesInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Controllers should be thin traffic cops — they receive input via params, delegate to the appropriate object, and decide what to render or redirect. Each action should be roughly 5-10 lines. If an action is longer, logic needs to be extracted.
Read the controller and classify each block of code:
| Code type | Extract to | Location |
| ------------------------------------------------------- | ---------------------- | -------------------------------------- |
| Business logic, multi-step operations, side effects | Service object | app/services/ |
| Complex queries, filtering, sorting, search | Query object | app/queries/ |
| Context-specific param validation and persistence | Form object | app/forms/ |
| Shared before_actions, auth, pagination, error handling | Controller concern | app/controllers/concerns/ |
| Complex view data assembly, formatting, display logic | Presenter / Decorator | app/presenters/ or app/decorators/ |
| Non-RESTful custom actions on a different concept | New RESTful controller | app/controllers/ |
| Simple CRUD, strong params, render/redirect | Keep on controller | — |
Use for any business logic that goes beyond simple CRUD. A controller action should call one service at most.
Before:
def create
@player = Player.new(player_params)
@player.team = Team.find(params[:team_id])
@player.contract_start = Time.current
@player.status = :active
if @player.save
PlayerMailer.welcome(@player).deliver_later
Analytics.track("player_signed", player_id: @player.id)
NotifyScoutsJob.perform_later(@player.team_id)
redirect_to @player, notice: "Player signed"
else
render :new, status: :unprocessable_entity
end
end
After:
# Controller
def create
result = Players::SignPlayer.new(player_params, team_id: params[:team_id]).call
if result.success?
redirect_to result.player, notice: "Player signed"
else
@player = result.player
render :new, status: :unprocessable_entity
end
end
# app/services/players/sign_player.rb
module Players
class SignPlayer
def initialize(params, team_id:)
@params = params
@team_id = team_id
end
def call
player = Player.new(@params)
player.team = Team.find(@team_id)
player.contract_start = Time.current
player.status = :active
if player.save
send_notifications(player)
Result.new(success: true, player: player)
else
Result.new(success: false, player: player)
end
end
private
def send_notifications(player)
PlayerMailer.welcome(player).deliver_later
Analytics.track("player_signed", player_id: player.id)
NotifyScoutsJob.perform_later(player.team_id)
end
Result = Struct.new(:success, :player, keyword_init: true) do
alias_method :success?, :success
end
end
end
Use when index actions have complex filtering, sorting, or search logic.
Before:
def index
@players = Player.where(team_id: params[:team_id])
@players = @players.where(position: params[:position]) if params[:position].present?
@players = @players.where("age >= ?", params[:min_age]) if params[:min_age].present?
@players = @players.where(status: :active) unless params[:include_inactive]
@players = @players.joins(:stats).order("stats.war DESC")
@players = @players.page(params[:page]).per(25)
end
After:
# Controller
def index
@players = Players::FilterQuery.new(params).call
end
# app/queries/players/filter_query.rb
module Players
class FilterQuery
def initialize(params, relation: Player.all)
@params = params
@relation = relation
end
def call
filter_by_team
filter_by_position
filter_by_age
filter_by_status
sort_and_paginate
@relation
end
private
def filter_by_team
@relation = @relation.where(team_id: @params[:team_id]) if @params[:team_id].present?
end
def filter_by_position
@relation = @relation.where(position: @params[:position]) if @params[:position].present?
end
def filter_by_age
@relation = @relation.where("age >= ?", @params[:min_age]) if @params[:min_age].present?
end
def filter_by_status
@relation = @relation.where(status: :active) unless @params[:include_inactive]
end
def sort_and_paginate
@relation = @relation.joins(:stats).order("stats.war DESC").page(@params[:page]).per(25)
end
end
end
Use for shared behavior across multiple controllers: authentication, authorization, pagination, error handling, locale setting.
# app/controllers/concerns/paginatable.rb
module Paginatable
extend ActiveSupport::Concern
private
def page
params[:page] || 1
end
def per_page
[params[:per_page].to_i, 100].min.nonzero? || 25
end
end
# app/controllers/concerns/error_handleable.rb
module ErrorHandleable
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :bad_request
end
private
def not_found
render json: { error: "Not found" }, status: :not_found
end
def bad_request(exception)
render json: { error: exception.message }, status: :bad_request
end
end
Guidelines for controller concerns:
Use when an action assembles complex data for the view that isn't a direct model attribute. Keeps view logic out of the controller.
# app/presenters/player_dashboard_presenter.rb
class PlayerDashboardPresenter
def initialize(player)
@player = player
end
def career_stats
@career_stats ||= @player.stats.group(:season).sum(:war)
end
def contract_status_label
return "Free Agent" if @player.contract_end&.past?
"Under Contract (#{@player.contract_end&.year})"
end
def trade_value_rating
case @player.trade_value
when 90.. then "Elite"
when 70..89 then "High"
when 50..69 then "Average"
else "Low"
end
end
end
# Controller
def show
@player = Player.find(params[:id])
@presenter = PlayerDashboardPresenter.new(@player)
end
When a controller has custom non-RESTful actions, it usually means there's a hidden resource. Extract it into its own controller with standard CRUD actions.
Before:
class PlayersController < ApplicationController
def trade
# ...
end
def release
# ...
end
def promote_to_roster
# ...
end
end
After:
# config/routes.rb
resources :players do
resource :trade, only: [:new, :create], controller: "players/trades"
resource :release, only: [:create], controller: "players/releases"
resource :roster_promotion, only: [:create], controller: "players/roster_promotions"
end
# app/controllers/players/trades_controller.rb
module Players
class TradesController < ApplicationController
def new
@player = Player.find(params[:player_id])
end
def create
result = Players::TradePlayer.new(Player.find(params[:player_id]), trade_params).call
# ...
end
end
end
Signs you need a new controller:
_params method. If you need different permitted params per action, consider separate controllers or form objects.class PlayersController < ApplicationController
before_action :set_player, only: [:show, :edit, :update, :destroy]
def index
@players = Players::FilterQuery.new(params).call
end
def show
@presenter = PlayerDashboardPresenter.new(@player)
end
def create
result = Players::SignPlayer.new(player_params, team_id: params[:team_id]).call
if result.success?
redirect_to result.player, notice: "Player signed"
else
@player = result.player
render :new, status: :unprocessable_entity
end
end
def update
if @player.update(player_params)
redirect_to @player, notice: "Player updated"
else
render :edit, status: :unprocessable_entity
end
end
def destroy
@player.destroy
redirect_to players_path, notice: "Player removed"
end
private
def set_player
@player = Player.find(params[:id])
end
def player_params
params.require(:player).permit(:name, :position, :age, :team_id)
end
end
rescue_from.development
Prepare a machine — a fresh laptop or a throwaway container — to run coding agents, before any repository exists. Detects which of Lisa's supported agents (Claude Code, Codex, Cursor, OpenCode, Antigravity, Copilot) are already installed, asks which credential manager the machine uses (Bitwarden, 1Password, Doppler, Vault, AWS, or none), and installs only what is missing, each by its vendor's own preferred method. Idempotent, headless by default, and emits a Dockerfile for a spin-up/spin-down environment. Run it on a new machine, in a container, or before cloning anything.
tools
Provision and verify a remote execution environment for a host project — Codex Cloud today, other remote surfaces as they are added. Generates a repository-owned setup script that installs the declared toolchain, materializes secrets through lisa-secrets-access, and runs the project's own hook. Provisions by API where one exists, by driving the vendor console where one does not, and by emitting exact config otherwise — then proves the result with the same read-back regardless of which tier did the work. Use before dispatching any work with executionEnv.
tools
Bring a developer's machine in line with the toolchain the project declares. Reports every tool in remoteEnv.tools that is missing, outdated, or unpinned for this platform, and installs the missing ones into ~/.local/bin from the same pinned, checksummed entries the remote surfaces use — but only when asked. Same manifest, same pins, same installers as lisa-setup-remote-env; what differs is consent and that the pin is a floor rather than an equality. Run it on a fresh checkout, after a manifest change, or when a tool fails at the moment of use.
tools
Route one unit of work to a remote execution surface. Reads the executionEnv parameter (local by default, codex-cloud or claude-web today), verifies the environment is provisioned and bound to this repository, submits a thin skill invocation, records the task identifier to .lisa/remote-dispatch.json, and exits without polling. Routing only — the remote runs the identical skill from the identical repository. Composable and inline: other skills invoke it via the Skill tool rather than users calling it directly.