Move Edit Tracking to a Service

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.

← Back to all articles