Keeping an enjoyable Rails application, years after years

Hunter.io and its API are served by a single, monolithic application built in Ruby on Rails. The first commit was performed by Antoine on March, 16th 2015, on the 4.2.0 version:

The initial commit, where the journey started
The initial commit, where the journey started

Today, more than 5 years later, we’re still working on the same repository. Fortunately, we’re on a more recent Rails version. More importantly, we’re still having fun and enjoyment working on this application on a daily basis.

Here are a few conventions, best practices and tools we’ve put in place to ensure our application remains up-to-date, clean and of course, enjoyable.

Staying up-to-date

When updates aren’t automated, it’s very demanding to commit yourself to check for outdated or unsecure dependencies on a regular basis. Hopefully, Dependabot, natively integrated into GitHub, provides a really efficient way to know when dependencies are outdated. It also gives a way to update them in a few clicks.

Dependabot regularly checks the dependencies and creates a new PR on the repository once one of them is outdated or has security issues:

PR created by Dependabot for a new Rails version
PR created by Dependabot for a new Rails version

As our CI starts a new build every time a push occurs on any branch, we immediately know if the update worked and can be safely merged in production. No more reason for being late on updates. This is also how we always run the latest stable Rails version just a few days after it’s released.

We apply the same philosophy to any 3rd party or piece of software that interacts with our Rails application. Even though this may not always be automated, we make it a point of honour to run the latest versions of them. It reduces the risk of technical debt and increases a lot the developer’s enjoyment: who likes to browse outdated documentation pages?

Taking the best of Rails

Ruby on Rails is particularly known for its simplicity and the fast development it enables. This is great for folks who want to quickly turn an idea into a product. But that’s also, more surprisingly regarding the above, a framework that is still amazing after years of experience.

Let’s take Active Record callbacks as an example. A junior developer (or a senior one in a rush) might write the following code to send a welcome mail after a user has been created:

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    user = User.create(params[:user])

    # validate user creation

    user.send_welcome_mail

    redirect_to welcome_path
  end
end

Active Records, such as our User model here, actually come with really handy callbacks. They let you attach code to certain events in the lifecycle of the models, such as creation or update.

This allows to simplify the controller code and decouple the mail sending from the user creation:

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    User.create(params[:user])

    redirect_to welcome_path
  end
end

# app/models/user.rb
class User < ApplicationRecord
  after_create :send_welcome_mail

  private

    def send_welcome_mail
      # logic to send the welcome mail
    end
end

This example is basic. But it illustrates how Rails can help make the controller actions super light and move the model-related logic to the right place. That’s why we try to use them as much as possible in our codebase.

Dirty attributes is also a really handy Rails feature that doesn’t come up at first, but which is really convenient for readability. Here’s what they enable, in a few verbose examples:

# app/models/user.rb
class User < ApplicationRecord
  before_update :synchronize_with_cms

  after_update :notify_for_email_change,
               :unsubscribe_from_email_notifications

  private

    # Private: synchronizes the new login with the CMS before
    # updating the record.
    # A failure here will prevent the record to be updated.
    #
    def synchronize_with_cms
      return unless will_save_change_to_login?

      # logic to sync new login with CMS
    end

    def notify_for_email_change
      return unless saved_change_to_email?

      # logic to send the notification
    end

    def unsubscribe_from_email_notifications
      return unless saved_change_to_subscribed?(to: false)

      # logic to unsubscribe the user
    end
end

In the end, a tiny set of methods makes it really clean to perform actions only when an attribute is or will be touched. No need to keep track of the previous record and compare it with the updated one, for example.

Another Active Record feature that we use a lot: Validations. Rails comes with a set of default validators (presence, uniqueness, format, etc.), that can be combined with conditions. Let’s go back to our User model:

# app/models/user.rb
class User < ApplicationRecord
  validates :login,
            presence: true,
            uniqueness: true,
            length: { in: 6..20 }

  validates :avatar, presence: true, if: :premium?
end

For models that require some extended validation, Rails lets you define custom validation methods or even custom validators:

# app/models/user.rb
class User < ApplicationRecord
  validate :strong_password # custom validation method

  validates_with User::Validator
end

# app/models/user/validator.rb
class User::Validator < ActiveModel::Validator
  def validate(user)
    validate_first_name
  end
end

Thanks to callbacks, dirty attributes and validations, we remove from the controllers all the models logic. They only focus on their job: receiving the request, transmitting it to the right part of the application and returning the appropriate response.

In our obsession to slim our controllers down, we also extensively use Action Controllers filters. Just like Active Record callbacks, they are methods called before, after or around a controller method. Let’s take an example from our codebase, whose goal is to prevent a Free user to use a Premium feature, attachments:

# app/controllers/attachments_controller.rb
class AttachmentsController < ApplicationController
  before_action :ensure_premium_user, only: :create

  def create
    # logic to create the attachment
  end

  private

    def ensure_premium_user
      return if Current.user.premium?

      error = "Upgrade to any Premium plan to insert attachments."

      render json: { error: error }, status: :unauthorized
    end
end

These are a few examples on how we follow and stick to the Rails-way of doing things. This helps us to keep a codebase that is easy read and to expand, even for newcomers.

For those who want to dig deeper, we also follow these best practices:

  • We never ever write monkey patches, even though it’s seen as a Ruby great power. It just doesn’t feel right and increases the chance of maintainability issues.
  • We always follow the Rails conventions, even when it requires some migration work. Credentials, introduced in Rails 5.2, or Webpacker, introduced in 6.0, are great examples.
  • We use as few gems as possible.

Testing wisely

Our test suite is made up of 3 kinds of tests. They all bring their own granularity and abstraction level. As we’re not a bank and don’t send anything into space, a 100% code coverage doesn’t make sense in our context. Instead, we focus on writing the right amount of tests in regards to the criticality of a feature.

Let’s start with the highest-level type: feature tests. In this kind of tests, we want to ensure that the application, externally, behaves as expected. To do so, we rely on Capybara and Selenium. The first one starts the application and provides a DSL to interact with it, through a Selenium driver (a browser). Then, we write regular RSpec expectations:

# spec/features/users/registrations_controller_spec.rb
describe Users::RegistrationsController, type: :feature do
  it "allows sign up" do
    visit new_user_registration_path

    expect(page).to have_content("Create a free account")

    fill_in("Work email address", with: "john@mail.com")

    click_on "Continue"

    fill_in("First name", with: "John")
    fill_in("Last name", with: "Doe")
    fill_in("Password", with: "strong_password")

    click_on "Create account"

    expect(page).to have_current_path(users_check_your_inbox_path)
    expect(page).to have_content(
      "Please check your inbox to validate your account.",
    )
  end
end

Feature tests are really suited for Web applications as they ensure we never break any user-facing code. Unfortunately, they are slow to execute and also a bit flaky (in particular when the frontend code interacts with 3rd parties). For these reasons, it’s not possible to cover all the controllers code with them. On our end, we made the choice to limit the feature tests to mission-critical parts of the product: sign-up, suscription and most used features.

Second type of tests we practice: requests tests. Although they can also be considered as integration tests, they are a bit different than feature tests because they don’t interact with the application through HTML pages, but only through HTTP requests. They are especially useful in our case to test out all our API endpoints.

Here’s an example from our test suite:

# spec/requests/api/account_controller_spec.rb
describe Api::AccountController, type: :request do
  before do
    @user = create(:user)
  end

  it "user can get his account" do
    get "/v2/account", params: { api_key: @user.api_keys.first.token }

    expect(response.status).to eq 200

    body = JSON.parse(response.body)

    expect(body["data"]["email"]).to eq @user.email
    expect(body["data"]["plan_name"]).to eq @user.subscription.plan_name
    expect(body["data"]["plan_level"]).to eq @user.subscription.level

    # more expectations...
  end
end

Finally, the last kind of tests we have, which are also the most laborious to write, are the models tests. They can be considered as unit tests and ensure public methods from our models behave as expected, validators are well written, etc.

# spec/models/user_spec.rb
describe User, type: :model do
  it { should have_many(:api_keys) }
  it { should belong_to(:team) }
  it { should validate_presence_of(:email) }

  it "saves the primary email at creation" do
    create(:user, email: "john.doe+hunter@mail.com")

    expect(User.last.primary_email).to eq "john.doe@mail.com"
  end
end

By testing all our mailers, models and workers this way, we make sure that all our codebase has a great test coverage.

Selecting the right test type and granularity, combined with the right tools, make testing a nice and painless part of our daily job. We’re definitely convinced that in the long run, it’s a game changer in keeping a clean application, without any dead code.

Monitoring carefully

The previous sections gave a quick overview of how we keep an up-to-date application and how we write and test code following the Rails way. But code is written to be executed, and production code needs to be monitored, right? Especially when, in our case, deployments are fully automated and occur multiple times a day. Here are the tools we use to ensure our application runs just as expected.

Sentry is the error tracking software we’ve used since the beginning of Hunter to track issues and regressions. As it’s synced with our GitHub repository, even fixing production issues becomes enjoyable:

Monitoring issues with Sentry
Monitoring issues with Sentry

With Sentry, we make sure that we never miss any production error or regression. It also forces us to be proactive when an error occurs.

Another side of the monitoring concerns performance (speed, error rate, slow transactions, etc.). For this purpose, we trust New Relic One:

Monitoring performance with New Relic One
Monitoring performance with New Relic One

It helps us find performance regressions, bottlenecks and slow queries, as soon as they appear in production.

Conclusion

Working all day long on a single elderly and monolithic Rails application may sound boring at first. But with the right tools and best practices we’ve set up, we made this an enjoyable and thriving experience.

We’re not Rails gurus and recognize its limits: all our backend is built in Go. Though, we consider that a lot can be achieved with this framework, despite the hype on more splitted architectures and newer technologies.

If you’d would like to join us, we’re about to open a Ruby on Rails position. Subscribe here to get notified when we publish it.

We’re always open to suggestions. Just email us at engineering@hunter.io if you have feedback to share!