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

Beyond the Hype: Practical lessons in Long-Term...

Beyond the Hype: Practical lessons in Long-Term Rails maintenance

New Rails features like Hotwire, Kamal, and Solid AllTheThings are super cool, but not everyone uses them daily. Many of us work on Rails apps that have grown large and complex. To keep these apps thriving, we need to stay up to date, deal with technical debt, and add new features that are important for our business needs while ensuring nothing breaks for our customers.

In this talk, I’ll give you a real-life look at how a small team of engineers keeps an almost 20-year-old monolithic Rails application at Harvest running strong and ready for the future. I’ll share how our workflows have changed (like how we write PR descriptions or how we advocate for refactoring through exploration), how we stay up to date with Rails updates (including why we chose to utilize new out-of-the-box technologies and why we decided to keep older libraries in other situations), and the practical lessons and strategies we use to keep things running smoothly, like observability, feature flags, and other utils and gems like scientist.

Julia López

April 29, 2025
Tweet

More Decks by Julia López

Other Decks in Programming

Transcript

  1. Beyond the Hype Julia López – Balkan Ruby 2025 Practical

    lessons in Long-Term Rails maintenance
  2. Julia López From Barcelona ☀ ❤ Rails since 2011 Refactoring

    🧹 & Upgrades ⏫ 🔗 https://julialopez.dev
  3. Danny Wen - https://blog.groovehq.com/harvest-danny-wen-interview “We’ve been using Excel for timesheets

    for our clients, and we thought, it would be so much easier if this was on the web.”
  4. • Thoughtful estimations based on research and/or how old the

    code is • Advocate for research, improvements, and cleanups! • Help de f ine and shape features Project management
  5. Project management • Thoughtful estimations based on research and/or how

    old the code is • Advocate for research, improvements, and cleanups! • Help de f ine and shape features
  6. • Thoughtful estimations based on research and/or how old the

    code is • Advocate for research, improvements, and cleanups! • Help de f ine and shape features Project management
  7. • Proactive update of dependencies used in the code you

    are currently working on • Use latest patterns when implementing new features • Try to improve or delete the code you see on your way Implementation
  8. • Proactive update of dependencies used in the code you

    are currently working on • Use latest patterns when implementing new features • Try to improve or delete the code you see on your way Implementation
  9. Implementation • Proactive update of dependencies used in the code

    you are currently working on • Use latest patterns when implementing new features • Try to improve or delete the code you see on your way!
  10. • Verbose PR descriptions • Enforced a minimum number of

    reviewers • Advocate for maintainability on your review • Everybody is welcome to chime in! Pull requests & code reviews
  11. Many of us at some point “These are complicated changes

    we discussed. More [LINK THAT DOESN’T LONGER WORK]”
  12. • Verbose PR descriptions • Enforce a minimum number of

    reviewers • Advocate for maintainability on your review • Everybody is welcome to chime in! Pull requests & code reviews
  13. • Verbose PR descriptions • Enforce a minimum number of

    reviewers • Advocate for maintainability on your review • Everybody is welcome to chime in! Pull requests & code reviews
  14. • Verbose PR descriptions • Enforce a minimum number of

    reviewers • Advocate for maintainability on your review • Everybody is welcome to chime in! Pull requests & code reviews
  15. • Not a safety net • They prevent issues, not

    just catch them • Check beyond whether the feature works or not Quality Assurance
  16. • Not a safety net • They prevent issues, not

    just catch them • Check beyond whether the feature works or not Quality Assurance
  17. • Not a safety net • They prevent issues, not

    just catch them • Check beyond whether the feature works or not Quality Assurance
  18. • The engineer who deploys the change is responsible for

    making sure they have not introduced any regressions • Follow-up on needed changes based on observations in production Deployment and beyond
  19. • The engineer who deploys the change is responsible for

    making sure they have not introduced any regressions • Follow-up on needed changes based on observations in production Deployment and beyond
  20. • Engineers rotate every sprint • Handle bugs outside of

    the scope of the squads • Oneshot requests • Help with troubleshooting • Low-hanging fruit improvements Delta Force
  21. • Engineers rotate every sprint • Handle bugs outside of

    the scope of the squads • Oneshot requests • Help with troubleshooting • Low-hanging fruit improvements Delta Force
  22. • Engineers rotate every sprint • Handle bugs outside of

    the scope of the squads • Oneshot requests • Help with troubleshooting • Low-hanging fruit improvements Delta Force
  23. • Engineers rotate every sprint • Handle bugs outside of

    the scope of the squads • Oneshot requests • Help with troubleshooting • Low-hanging fruit improvements Delta Force
  24. • Engineers rotate every sprint • Handle bugs outside of

    the scope of the squads • Oneshot requests • Help with troubleshooting • Low-hanging fruit improvements Delta Force
  25. • Engineering productivity (local setup, linting, automation, CI/CD, etc.) •

    Observability • Core system functionalities • Major upgrades • Security Platform Development
  26. • Engineering productivity (local setup, linting, automation, CI/CD, etc.) •

    Observability • Core system functionalities • Major upgrades • Security Platform Development
  27. • Engineering productivity (local setup, linting, automation, CI/CD, etc.) •

    Observability • Core system functionalities • Major upgrades • Security Platform Development
  28. • Engineering productivity (local setup, linting, automation, CI/CD, etc.) •

    Observability • Core system functionalities • Major upgrades • Security Platform Development
  29. • Engineering productivity (local setup, linting, automation, CI/CD, etc.) •

    Observability • Core system functionalities • Major upgrades • Security Platform Development
  30. AI

  31. • Explore unknowns before building a feature • Time-boxed •

    Deliver POC, f indings, and recommendations • Allows us to plan and estimate better Spikes
  32. • Explore unknowns before building a feature • Time-boxed •

    Deliver POC, f indings, and recommendations • Allows us to plan and estimate better Spikes
  33. • Explore unknowns before building a feature • Time-boxed •

    Deliver POC, f indings, and recommendations • Allows us to plan and estimate better Spikes
  34. • Explore unknowns before building a feature • Time-boxed •

    Deliver POC, f indings, and recommendations • Allows us to plan and estimate better Spikes
  35. version: 2 updates: - package-ecosystem: “docker" directory: ".docker/app/" schedule: interval:

    “monthly” # We don't want any general updates, only security ones open-pull-requests-limit: 0 - package-ecosystem: "bundler" directory: "/" schedule: interval: "daily" # We don't want any general updates, only security ones open-pull-requests-limit: 0 - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" # We don't want any general updates, only security ones open-pull-requests-limit: 0
  36. version: 2 updates: - package-ecosystem: “docker" directory: ".docker/app/" schedule: interval:

    “monthly” # We don't want any general updates, only security ones open-pull-requests-limit: 0 - package-ecosystem: "bundler" directory: "/" schedule: interval: "daily" # We don't want any general updates, only security ones open-pull-requests-limit: 0 - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" # We don't want any general updates, only security ones open-pull-requests-limit: 0
  37. version: 2 updates: - package-ecosystem: “docker" directory: ".docker/app/" schedule: interval:

    “monthly” # We don't want any general updates, only security ones open-pull-requests-limit: 0 - package-ecosystem: "bundler" directory: "/" schedule: interval: "daily" # We don't want any general updates, only security ones open-pull-requests-limit: 0 - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" # We don't want any general updates, only security ones open-pull-requests-limit: 0
  38. version: 2 updates: - package-ecosystem: “docker" directory: ".docker/app/" schedule: interval:

    “monthly” # We don't want any general updates, only security ones open-pull-requests-limit: 0 - package-ecosystem: "bundler" directory: "/" schedule: interval: "daily" # We don't want any general updates, only security ones open-pull-requests-limit: 0 - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" # We don't want any general updates, only security ones open-pull-requests-limit: 0
  39. // For gems and ruby versions, do major upgrades one

    at a time. For example, // go from 1.0.0 to 2.0.0 and then to 3.0.0 instead of 1.0.0 to 3.0.0. { matchManagers: ['bundler', 'ruby-version'], separateMultipleMajor: true, },
  40. // For gems and ruby versions, do major upgrades one

    at a time. For example, // go from 1.0.0 to 2.0.0 and then to 3.0.0 instead of 1.0.0 to 3.0.0. { matchManagers: ['bundler', 'ruby-version'], separateMultipleMajor: true, },
  41. // For rails, separate patch and minor PR's. For example,

    first go from // 6.0.4 to 6.0.8 and then to 6.1.X instead of 6.1.X directly. { matchManagers: ['bundler'], matchPackageNames: ['rails'], separateMinorPatch: true, },
  42. // For rails, separate patch and minor PR's. For example,

    first go from // 6.0.4 to 6.0.8 and then to 6.1.X instead of 6.1.X directly. { matchManagers: ['bundler'], matchPackageNames: ['rails'], separateMinorPatch: true, },
  43. // Update DEFAULT_REF_INTEGRATION_SUITE in .buildkite/pipeline.yml { customType: 'regex', description: 'Update

    the integration-suite ref in pipeline configurations', fileMatch: [ '^.buildkite/pipeline\\.yml$', ], matchStrings: [ '\\sDEFAULT_REF_INTEGRATION_SUITE: ["\'](?<currentDigest>.*)["\']\\s', ], branch: 'main', datasourceTemplate: 'git-refs', depNameTemplate: 'https://github.com/harvesthq/integration-suite', },
  44. // Update DEFAULT_REF_INTEGRATION_SUITE in .buildkite/pipeline.yml { customType: 'regex', description: 'Update

    the integration-suite ref in pipeline configurations', fileMatch: [ '^.buildkite/pipeline\\.yml$', ], matchStrings: [ '\\sDEFAULT_REF_INTEGRATION_SUITE: ["\'](?<currentDigest>.*)["\']\\s', ], branch: 'main', datasourceTemplate: 'git-refs', depNameTemplate: 'https://github.com/harvesthq/integration-suite', },
  45. // Update DEFAULT_REF_INTEGRATION_SUITE in .buildkite/pipeline.yml { customType: 'regex', description: 'Update

    the integration-suite ref in pipeline configurations', fileMatch: [ '^.buildkite/pipeline\\.yml$', ], matchStrings: [ '\\sDEFAULT_REF_INTEGRATION_SUITE: ["\'](?<currentDigest>.*)["\']\\s', ], branch: 'main', datasourceTemplate: 'git-refs', depNameTemplate: 'https://github.com/harvesthq/integration-suite', },
  46. // Update DEFAULT_REF_INTEGRATION_SUITE in .buildkite/pipeline.yml { customType: 'regex', description: 'Update

    the integration-suite ref in pipeline configurations', fileMatch: [ '^.buildkite/pipeline\\.yml$', ], matchStrings: [ '\\sDEFAULT_REF_INTEGRATION_SUITE: ["\'](?<currentDigest>.*)["\']\\s', ], branch: 'main', datasourceTemplate: 'git-refs', depNameTemplate: 'https://github.com/harvesthq/integration-suite', },
  47. // Update DEFAULT_REF_INTEGRATION_SUITE in .buildkite/pipeline.yml { customType: 'regex', description: 'Update

    the integration-suite ref in pipeline configurations', fileMatch: [ '^.buildkite/pipeline\\.yml$', ], matchStrings: [ '\\sDEFAULT_REF_INTEGRATION_SUITE: ["\'](?<currentDigest>.*)["\']\\s', ], branch: 'main', datasourceTemplate: 'git-refs', depNameTemplate: 'https://github.com/harvesthq/integration-suite', },
  48. ❯ bundle outdated Fetching gem metadata from https://rubygems.org/......... Resolving dependencies...

    Gem Current Latest Requested Groups actioncable 7.2.2.1 8.0.2 actionmailbox 7.2.2.1 8.0.2 actionmailer 7.2.2.1 8.0.2 actionpack 7.2.2.1 8.0.2 actiontext 7.2.2.1 8.0.2 actionview 7.2.2.1 8.0.2 activejob 7.2.2.1 8.0.2 activemodel 7.2.2.1 8.0.2 activerecord 7.2.2.1 8.0.2 activestorage 7.2.2.1 8.0.2 activesupport 7.2.2.1 8.0.2 attr_encrypted 4.0.0 4.2.0 >= 0 default browser 4.0.0 6.2.0 >= 0 default prometheus-client 4.2.2 4.2.4 >= 0 default rack 2.2.13 3.1.13 ~> 2 default rack-session 1.0.2 2.1.0 rack-utf8_sanitizer 1.9.1 1.10.1 >= 0 default rackup 1.0.1 2.2.1 rails 7.2.2.1 8.0.2 = 7.2.2.1 default railties 7.2.2.1 8.0.2 rubocop 1.75.1 1.75.2 = 1.75.1 development, test sidekiq 7.3.1 8.0.2 >= 0 default sidekiq-cron 1.12.0 2.2.0 >= 0 default sidekiq-pro 7.3.1 8.0.1 >= 0 default xero-ruby 4.3.1 11.0.0 >= 0 default
  49. • We don’t have to keep long-running branches • A/B

    testing • Toggle features on/o ff without deploying – great rollback mechanism • Roll out to speci f ic users or groups – test in production with internal companies Feature f lags
  50. • We don’t have to keep long-running branches • A/B

    testing • Toggle features on/o ff without deploying – great rollback mechanism • Roll out to speci f ic users or groups – test in production with internal companies Feature f lags
  51. • We don’t have to keep long-running branches • A/B

    testing • Toggle features on/o ff without deploying – great rollback mechanism • Roll out to speci f ic users or groups – test in production with internal companies Feature f lags
  52. • We don’t have to keep long-running branches • A/B

    testing • Toggle features on/o ff without deploying – great rollback mechanism • Roll out to speci f ic users or groups – test in production with internal companies Feature f lags
  53. require "scientist" class MyWidget def allows?(user) experiment = Scientist::Default.new "widget-permissions"

    experiment.use { model.check_user(user).valid? } # old way experiment.try { user.can?(:read, model) } # new way experiment.run end end How scientist works?
  54. require "scientist" class MyWidget def allows?(user) experiment = Scientist::Default.new "widget-permissions"

    experiment.use { model.check_user(user).valid? } # old way experiment.try { user.can?(:read, model) } # new way experiment.run end end How scientist works?
  55. require "scientist" class MyWidget def allows?(user) experiment = Scientist::Default.new "widget-permissions"

    experiment.use { model.check_user(user).valid? } # old way experiment.try { user.can?(:read, model) } # new way experiment.run end end How scientist works?
  56. require "scientist" class MyWidget def allows?(user) experiment = Scientist::Default.new "widget-permissions"

    experiment.use { model.check_user(user).valid? } # old way experiment.try { user.can?(:read, model) } # new way experiment.run end end How scientist works?
  57. require "scientist" class MyWidget def allows?(user) experiment = Scientist::Default.new "widget-permissions"

    experiment.use { model.check_user(user).valid? } # old way experiment.try { user.can?(:read, model) } # new way experiment.run end end How scientist works?
  58. require "scientist/experiment" class MyExperiment include Scientist::Experiment attr_accessor :name def initialize(name)

    @name = name end def enabled? true end def raised(operation, error) p "Operation '#{operation}' failed with error '#{error.inspect}'" super # will re-raise end def publish(result) p result end end How scientist works?
  59. require "scientist/experiment" class MyExperiment include Scientist::Experiment attr_accessor :name def initialize(name)

    @name = name end def enabled? true end def raised(operation, error) p "Operation '#{operation}' failed with error '#{error.inspect}'" super # will re-raise end def publish(result) p result end end How scientist works?
  60. require "scientist/experiment" class MyExperiment include Scientist::Experiment attr_accessor :name def initialize(name)

    @name = name end def enabled? true end def raised(operation, error) p "Operation '#{operation}' failed with error '#{error.inspect}'" super # will re-raise end def publish(result) p result end end How scientist works?
  61. require "scientist/experiment" class MyExperiment include Scientist::Experiment attr_accessor :name def initialize(name)

    @name = name end def enabled? true end def raised(operation, error) p "Operation '#{operation}' failed with error '#{error.inspect}'" super # will re-raise end def publish(result) p result end end How scientist works?
  62. require "scientist/experiment" class MyExperiment include Scientist::Experiment attr_accessor :name def initialize(name)

    @name = name end def enabled? true end def raised(operation, error) p "Operation '#{operation}' failed with error '#{error.inspect}'" super # will re-raise end def publish(result) p result end end How scientist works?
  63. require "scientist/experiment" class MyExperiment include Scientist::Experiment attr_accessor :name def initialize(name)

    @name = name end def enabled? true end def raised(operation, error) p "Operation '#{operation}' failed with error '#{error.inspect}'" super # will re-raise end def publish(result) p result end end How scientist works?
  64. class ApplicationExperiment include Scientist::Experiment attr_reader :name def initialize(name, enabled: false)

    @name = name @enabled = enabled end def enabled? @enabled end def publish(result) Prom.summaries["scientist_experiments"].observe result.control.duration, name: name, observation: "control" Prom.summaries["scientist_experiments"].observe result.candidates.first.duration, name: name, observation: "candidate" if result.matched? Prom.counters["science_experiments"].observe 1, name: name, kind: "match" else Prom.counters["science_experiments"].observe 1, name: name, kind: "mismatch" store_mismatch_data(result) end end private def store_mismatch_data(result) payload = { name: name, context: context, execution_order: result.observations.map(&:name), control: observation_payload(result.control), candidate: observation_payload(result.candidates.first), } key = "science.#{name}.mismatch" $redis.lpush key, payload.to_json $redis.ltrim key, 0, 50 end def observation_payload(observation) if observation.raised? { exception: observation.exception.class, message: observation.exception.message, backtrace: observation.exception.backtrace, } else { value: observation.cleaned_value } end end end Default experiment
  65. Default experiment class ApplicationExperiment include Scientist::Experiment attr_reader :name def initialize(name,

    enabled: false) @name = name @enabled = enabled end def enabled? @enabled end def publish(result) Prom.summaries["scientist_experiments"].observe result.control.duration, name: name, observation: "control" Prom.summaries["scientist_experiments"].observe result.candidates.first.duration, name: name, observation: "candidate" if result.matched? Prom.counters["science_experiments"].observe 1, name: name, kind: "match" else Prom.counters["science_experiments"].observe 1, name: name, kind: "mismatch" store_mismatch_data(result) end end private def store_mismatch_data(result) payload = { name: name, context: context, execution_order: result.observations.map(&:name), control: observation_payload(result.control), candidate: observation_payload(result.candidates.first),
  66. Default experiment class ApplicationExperiment include Scientist::Experiment attr_reader :name def initialize(name,

    enabled: false) @name = name @enabled = enabled end def enabled? @enabled end def publish(result) Prom.summaries["scientist_experiments"].observe result.control.duration, name: name, observation: "control" Prom.summaries["scientist_experiments"].observe result.candidates.first.duration, name: name, observation: "candidate" if result.matched? Prom.counters["science_experiments"].observe 1, name: name, kind: "match" else Prom.counters["science_experiments"].observe 1, name: name, kind: "mismatch" store_mismatch_data(result) end end private def store_mismatch_data(result) payload = { name: name, context: context, execution_order: result.observations.map(&:name), control: observation_payload(result.control), candidate: observation_payload(result.candidates.first),
  67. attr_reader :name def initialize(name, enabled: false) @name = name @enabled

    = enabled end def enabled? @enabled end def publish(result) Prom.summaries["scientist_experiments"].observe result.control.duration, name: name, observation: "control" Prom.summaries["scientist_experiments"].observe result.candidates.first.duration, name: name, observation: "candidate" if result.matched? Prom.counters["science_experiments"].observe 1, name: name, kind: "match" else Prom.counters["science_experiments"].observe 1, name: name, kind: "mismatch" store_mismatch_data(result) end end private def store_mismatch_data(result) payload = { name: name, context: context, execution_order: result.observations.map(&:name), control: observation_payload(result.control), candidate: observation_payload(result.candidates.first), } key = "science.#{name}.mismatch" Default experiment
  68. Experiment with SQL queries experiment = Scientist::Default.new.new("dtr-scope-experiment", enabled: true) experiment.use

    { control_scope } experiment.try { candidate_scope } experiment.compare do |control_scope, candidate_scope| control_scope.pluck(:id) == candidate_scope.pluck(:id) end experiment.clean { |scope| scope.pluck(:id) } experiment.run
  69. Experiment with SQL queries experiment = Scientist::Default.new.new("dtr-scope-experiment", enabled: true) experiment.use

    { control_scope } experiment.try { candidate_scope } experiment.compare do |control_scope, candidate_scope| control_scope.pluck(:id) == candidate_scope.pluck(:id) end experiment.clean { |scope| scope.pluck(:id) } experiment.run
  70. Experiment with SQL queries experiment = Scientist::Default.new.new("dtr-scope-experiment", enabled: true) experiment.use

    { control_scope } experiment.try { candidate_scope } experiment.compare do |control_scope, candidate_scope| control_scope.pluck(:id) == candidate_scope.pluck(:id) end experiment.clean { |scope| scope.pluck(:id) } experiment.run
  71. Experiment with SQL queries experiment = Scientist::Default.new.new("dtr-scope-experiment", enabled: true) experiment.use

    { control_scope } experiment.try { candidate_scope } experiment.compare do |control_scope, candidate_scope| control_scope.pluck(:id) == candidate_scope.pluck(:id) end experiment.clean { |scope| scope.pluck(:id) } experiment.run
  72. Experiment with SQL queries experiment = Scientist::Default.new.new("dtr-scope-experiment", enabled: true) experiment.use

    { control_scope } experiment.try { candidate_scope } experiment.compare do |control_scope, candidate_scope| control_scope.pluck(:id) == candidate_scope.pluck(:id) end experiment.clean { |scope| scope.pluck(:id) } experiment.run
  73. Experiment with SQL queries experiment = Scientist::Default.new.new("dtr-scope-experiment", enabled: true) experiment.use

    { control_scope } experiment.try { candidate_scope } experiment.compare do |control_scope, candidate_scope| control_scope.pluck(:id) == candidate_scope.pluck(:id) end experiment.clean { |scope| scope.pluck(:id) } experiment.run ✅
  74. Experiment with caching experiment = Scientist::Default.new(name: "project-tab-without-inner-caching", enabled: true) experiment.context

    = { project: @project } experiment.use { cache_query { scope.connection.select_all(scope).to_a } } experiment.try { scope.connection.select_all(scope).to_a } experiment.run
  75. Experiment with caching experiment = Scientist::Default.new(name: "project-tab-without-inner-caching", enabled: true) experiment.context

    = { project: @project } experiment.use { cache_query { scope.connection.select_all(scope).to_a } } experiment.try { scope.connection.select_all(scope).to_a } experiment.run
  76. Experiment with caching experiment = Scientist::Default.new(name: "project-tab-without-inner-caching", enabled: true) experiment.context

    = { project: @project } experiment.use { cache_query { scope.connection.select_all(scope).to_a } } experiment.try { scope.connection.select_all(scope).to_a } experiment.run
  77. Experiment with caching experiment = Scientist::Default.new(name: "project-tab-without-inner-caching", enabled: true) experiment.context

    = { project: @project } experiment.use { cache_query { scope.connection.select_all(scope).to_a } } experiment.try { scope.connection.select_all(scope).to_a } experiment.run ✅
  78. Remove custom implementation experiment = Scientist::Default.new(name: "basic-auth", enabled: true) experiment.use

    { http_auth_user_and_password } experiment.try { ActionController::HttpAuthentication::Basic.user_name_and_password(request) } experiment.run
  79. Remove custom implementation experiment = Scientist::Default.new(name: "basic-auth", enabled: true) experiment.use

    { http_auth_user_and_password } experiment.try { ActionController::HttpAuthentication::Basic.user_name_and_password(request) } experiment.run
  80. Remove custom implementation experiment = Scientist::Default.new(name: "basic-auth", enabled: true) experiment.use

    { http_auth_user_and_password } experiment.try { ActionController::HttpAuthentication::Basic.user_name_and_password(request) } experiment.run ✅