Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Slowly we rot: signs of a Rails app’s decay

Sponsored · SiteGround - Reliable hosting with speed, security, and support you can count on. →

Slowly we rot: signs of a Rails app’s decay

Rails apps rarely collapse all at once. What starts as a TODO comment quietly becomes code you're afraid to touch: hard to understand, harder to change, and worse with every workaround. You might be eyeing a rewrite but, do you actually know how it got there?

After 13 years consulting on Rails apps, I've seen the same patterns of rot show up again and again. This talk is about those patterns: how they start, why they compound, and what you can actually do about them before they become someone else's problem (or yours, six months from now).

Presented at Rocky Mountain Ruby 2026

Avatar for Fernando Perales

Fernando Perales

September 28, 2026

More Decks by Fernando Perales

Other Decks in Technology

Transcript

  1. Fernando Perales Development Team Lead @ thoughtbot From Guadalajara, Mexico

    Host RubyMX (on hold for now) Clearance gem maintainer 13 years consulting on Rails apps About Me
  2. When was the last time you ran your entire test

    suite locally? All your test suite
  3. When was the last time you ran your entire test

    suite locally? Do you know how long it takes?
  4. When was the last time you ran your entire test

    suite locally? “We don’t”
  5. When was the last time you ran your entire test

    suite locally? “We don’t” Push → CI runs → work on something else
  6. Where does the time go? # What user wants: let(:account)

    { create(:account) } ➡ Factories that recursively create objects you never use # What user gets: FactoryBot.define do factory :account do name { "Acme Inc." } after(:create) do |account| create_list(:user, 5, account: account) end end factory :user do association :account name { "Jane Doe" } after(:create) do |user| create_list(:notification, 10, user: user) end end factory :notification do association :user message { "Something happened" } end end
  7. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough it "formats the user's name" do user = create(:user, first_name: "Jane", last_name: "Doe") expect(user.full_name).to eq("Jane Doe") end # Preferred user = build(:user, first_name: "Jane", last_name: "Doe")
  8. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! class User < ApplicationRecord after_create :send_welcome_email def send_welcome_email UserMailer.welcome(self).deliver_now end end create(:user)
  9. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! ➡ let! everywhere describe User do let!(:admin) { create(:admin) } let!(:account) { create(:account) } let!(:project) { create(:project, account:) } it "validates the name" do user = build(:user, name: nil) expect(user).not_to be_valid end end
  10. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! ➡ let! everywhere ➡ Higher spec level than required # Integration it "shows the user's name" do user = create(:user, name: "Jane Doe") visit user_path(user) expect(page).to have_text("Jane Doe") end
  11. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! ➡ let! everywhere ➡ Higher spec level than required # Request spec it "shows the user's name" do user = create(:user, name: "Jane Doe") get user_path(user) expect(response.body).to include("Jane Doe") end
  12. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! ➡ let! everywhere ➡ Higher spec level than required # View spec it "shows the user's name" do assign(:user, build_stubbed(:user, name: "Jane Doe")) render expect(rendered).to include("Jane Doe") end
  13. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! ➡ let! everywhere ➡ Higher spec level than required # ViewComponent spec it "shows the user's name" do user = build_stubbed(:user, name: "Jane Doe") render_inline(UserCardComponent.new(user:)) expect(page).to have_text("Jane Doe") end
  14. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! ➡ let! everywhere ➡ Higher spec level than required ➡ File processing user.avatar.attach( io: File.open("spec/fixtures/files/ avatar.jpg"), filename: "avatar.jpg" ) user.avatar.variant(resize_to_limit: [500, 500]).processed
  15. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! ➡ let! everywhere ➡ Higher spec level than required ➡ File processing ➡ Network-shaped work even when stubbed stub_request(:post, "https:// api.example.com/users") .to_return(status: 200, body: '{"id":123}') 100.times do ApiClient.new.create_user(create(:user)) end
  16. Where does the time go? ➡ Factories that recursively create

    objects you never use ➡ Using create when build is enough ➡ Callbacks! ➡ let! everywhere ➡ Higher spec level than required ➡ File processing ➡ Network-shaped work even when stubbed ➡ Broad shared context shared_context "account setup" do let!(:account) { create(:account) } let!(:admin) { create(:admin, account:) } let!(:subscription) { create(:subscription, account:) } let!(:projects) { create_list(:project, 10, account:) } end describe Project do include_context "account setup" it "requires a title" do expect(build(:project, title: nil)).not_to be_valid end end
  17. Common codebase deviations ➡ app/services vs lib with no clear

    boundary app/services/payments/charge_customer.rb app/services/reports/export_csv.rb lib/billing/retry_payment.rb lib/reports/monthly_report.rb lib/customer_sync.rb — app/services/ # application use cases lib/ # reusable infrastructure / frameworkindependent code
  18. Common codebase deviations ➡ app/services vs lib with no clear

    boundary CreateOrder.call(user, params) ➡ Method signature drift CreateInvoice.new(account: account).call(order) Payments::Charge.call( customer: customer, amount: amount, metadata: {} ) OrderCreator.new(params, user, true, nil).execute
  19. Common codebase deviations ➡ app/services vs lib with no clear

    boundary ➡ Method signature drift ImportUsers.call ImportUsers.perform ImportUsers.execute ImportUsers.run
  20. Common codebase deviations ➡ app/services vs lib with no clear

    boundary ➡ Method signature drift ➡ Object responsibilities drift # A class starts as: class CreateOrder def call Order.create!(...) end end # Then, it grows: class CreateOrder def call validate_inventory calculate_tax create_order charge_card create_invoice send_email notify_slack track_event end end
  21. Common codebase deviations ➡ app/services vs lib with no clear

    boundary ➡ Method signature drift ➡ Object responsibilities drift ➡ Request / integration / controller specs all coexist # These may be testing the same behavior in different ways spec/requests/users_spec.rb spec/controllers/users_controller_spec.rb test/integration/user_flow_test.rb spec/system/user_signup_spec.rb
  22. ➡ Request / integration / controller app/services/ create_user.rb format_phone_number.rb calculate_tax.rb

    send_invoice.rb retry_payment.rb import_csv.rb normalize_address.rb check_permissions.rb specs all coexist # Some of them deserve better names Common codebase deviations ➡ app/services vs lib with no clear boundary ➡ Method signature drift ➡ Object responsibilities drift ➡ “Service objects” become the junk drawer TaxCalculator AddressNormalizer InvoiceMailer CsvImporter PaymentRetryPolicy AuthorizationPolicy
  23. Common codebase deviations ➡ app/services vs lib with no clear

    boundary ➡ Method signature drift ➡ Object responsibilities drift ➡ Request / integration / controller UserCreator CreateUser UserCreationService Users::Create CreateUserService specs all coexist # With namespaces ➡ “Service objects” become the junk Billing::ChargeCustomer Payments::ChargeCustomer Stripe::ChargeCustomer ChargeCustomer drawer ➡ Naming deviations
  24. Common codebase deviations ➡ app/services vs lib with no clear

    # Queries boundary # Some parts ➡ Method signature drift User.active.paid.recent ➡ Object responsibilities drift ➡ Request / integration / controller # Another place specs all coexist UsersQuery.new(active: true).call ➡ “Service objects” become the junk # Elsewhere drawer ➡ Naming deviations ➡ Logic placement User.where(active: true) .joins(:subscription) .where(...)
  25. Common codebase deviations ➡ app/services vs lib with no clear

    boundary ➡ Method signature drift ➡ Object responsibilities drift ➡ Request / integration / controller specs all coexist ➡ “Service objects” become the junk drawer ➡ Naming deviations ➡ Logic placement # Validations class User < ApplicationRecord validates :email, presence: true end # Other places where validations can be found UserForm CreateUser UsersController
  26. Common codebase deviations ➡ app/services vs lib with no clear

    # Business logic boundary after_create :charge_customer after_update :sync_crm after_commit :send_notifications ➡ Method signature drift ➡ Object responsibilities drift ➡ Request / integration / controller specs all coexist ➡ “Service objects” become the junk drawer ➡ Naming deviations ➡ Logic placement # But some business logic has a dedicated place CreateSubscription.call(...)
  27. Common codebase deviations ➡ app/services vs lib with no clear

    boundary ➡ Method signature drift # Authorization ➡ Object responsibilities drift before_action :require_admin ➡ Request / integration / controller specs all coexist authorize @record ➡ “Service objects” become the junk current_user.can_edit?(@record) drawer PermissionService.call(...) ➡ Naming deviations ➡ Logic placement
  28. Common codebase deviations ➡ app/services vs lib with no clear

    boundary ➡ Method signature drift # Serialization ➡ Object responsibilities drift render json: user ➡ Request / integration / controller UserSerializer.new(user) specs all coexist UserBlueprint.render(user) ➡ “Service objects” become the junk drawer ➡ Naming deviations ➡ Logic placement user.as_json(...)
  29. Common codebase deviations ➡ app/services vs lib with no clear

    boundary ➡ Method signature drift # Configuration ➡ Object responsibilities drift ENV["STRIPE_KEY"] ➡ Request / integration / controller Rails.application.credentials.stripe[:key] specs all coexist Settings.stripe.key ➡ “Service objects” become the junk drawer ➡ Naming deviations ➡ Logic placement Rails.configuration.x.stripe_key
  30. Maintenance becomes migration deprecated ≠ broken ..but the longer you

    stay, the more of the future cost becomes yours to carry.
  31. Complexity you haven’t earned We identify a problem Misguided solution

    Complexity increases: it brings new problems Why are we doing this?
  32. Complexity you haven’t earned We identify a problem Misguided solution

    Complexity increases: it brings new problems Why are we doing this? Regret
  33. Complexity you haven’t earned Before adding complexity: What are we

    objectively solving? What trade-offs are we accepting?
  34. What do these problems have in common? Friction appears Workaround

    Workaround becomes normal Original pain fades
  35. What do these problems have in common? Friction appears Workaround

    Workaround becomes normal Original pain fades Cost compounds
  36. Conclusion Key Takeaways • • Rot is gradual, not catastrophic

    Preserve your ability to change the system
  37. Conclusion Key Takeaways • • • Rot is gradual, not

    catastrophic Preserve your ability to change the system Make trade-offs explicit, and revisit them
  38. Conclusion Key Takeaways • • • • Rot is gradual,

    not catastrophic Preserve your ability to change the system Make trade-offs explicit, and revisit them In high school, we were allowed to ignore friction, but software teams don’t get that luxury