This is part 2 of the Controller Refactoring Epic.
Inline edit features often accumulate constants and methods that don't belong in controllers. Field whitelists, serialization logic, and audit record creation are domain concerns.
The Problem
class Admin::JobListingsController < ApplicationController
EDITABLE_FIELDS = %w[title description salary_min salary_max].freeze
EDITABLE_ARRAY_FIELDS = %w[skill_ids benefit_ids].freeze
def update_field
field = params[:field_name]
return head :unprocessable_entity unless valid_field?(field)
old_value = serialize_for_audit(job_listing.public_send(field), field)
if job_listing.update(job_listing_params)
new_value = serialize_for_audit(job_listing.public_send(field), field)
record_field_edit!(field, old_value, new_value)
render_success
else
render_error
end
end
private
def valid_field?(field)
EDITABLE_FIELDS.include?(field) || EDITABLE_ARRAY_FIELDS.include?(field)
end
def serialize_for_audit(value, field)
if EDITABLE_ARRAY_FIELDS.include?(field)
Array(value).join(", ")
else
value.to_s
end
end
def record_field_edit!(field, old_val, new_val)
return if old_val == new_val
EditRecord.create!(
editable: job_listing,
field: field,
old_value: old_val,
new_value: new_val,
user: current_user,
edited_at: Time.current
)
end
end
The controller now knows: which fields are editable, how to serialize values for different field types, how to create audit records, and when to skip recording (no change). That's too much domain knowledge.
The Solution
# app/services/job_listing_edit_service.rb
class JobListingEditService
EDITABLE_FIELDS = %w[title description salary_min salary_max].freeze
EDITABLE_ARRAY_FIELDS = %w[skill_ids benefit_ids].freeze
def initialize(record:, user:)
@record = record
@user = user
end
def valid_field?(field)
EDITABLE_FIELDS.include?(field) || EDITABLE_ARRAY_FIELDS.include?(field)
end
def read_field(field)
serialize_for_audit(@record.public_send(field), field)
end
def record_edit!(field, old_value, new_value)
return if old_value == new_value
EditRecord.create!(
editable: @record,
field: field,
old_value: old_value,
new_value: new_value,
user: @user,
edited_at: Time.current
)
end
private
def serialize_for_audit(value, field)
if EDITABLE_ARRAY_FIELDS.include?(field)
Array(value).join(", ")
else
value.to_s
end
end
end
# Controller becomes an orchestrator
class Admin::JobListingsController < ApplicationController
def update_field
field = params[:field_name]
return head :unprocessable_entity unless edit_service.valid_field?(field)
old_value = edit_service.read_field(field)
if job_listing.update(job_listing_params)
edit_service.record_edit!(field, old_value, edit_service.read_field(field))
render_success
else
render_error
end
end
private
def edit_service
@edit_service ||= JobListingEditService.new(record: job_listing, user: current_user)
end
end
Testing the Service
RSpec.describe JobListingEditService do
let(:job_listing) { create(:job_listing, title: "Old Title") }
let(:user) { create(:user) }
let(:service) { described_class.new(record: job_listing, user: user) }
describe "#record_edit!" do
it "creates an edit record when value changes" do
expect {
service.record_edit!("title", "Old Title", "New Title")
}.to change(EditRecord, :count).by(1)
end
it "skips recording when value unchanged" do
expect {
service.record_edit!("title", "Same", "Same")
}.not_to change(EditRecord, :count)
end
end
describe "#read_field" do
it "serializes array fields as comma-separated" do
job_listing.skill_ids = [1, 2, 3]
expect(service.read_field("skill_ids")).to eq "1, 2, 3"
end
end
end
The Pattern
Services own domain logic. Controllers orchestrate. When you see constants like EDITABLE_FIELDS in a controller, that's a sign the logic belongs elsewhere.