March 5th, 2024

Does ActiveRecord Need Another Layer of Abstraction?

ActiveRecord is arguably one of Rails’ best innovations and killer features. Lately, however, I’ve questioned if the abstraction goes far enough. It’s a great abstraction over SQL and I’ve come to believe that we need one more layer of abstraction for maintainable applications. We need the curation and encapsulation of queries.

The current level of abstraction, SQL

ActiveRecord for the most part is an abstraction over SQL, and a great one at that. It exposes both the business logic of your database table, but it also exposes the querying logic (where, limit, offset, find, etc.). While this is clearly an improvement over raw SQL, let’s look at why this abstraction is valuable using a pretty common Rails pattern.

class UserController
  def show
    @user = User.where(profile: params[:username], active: true).first
  end
end

Now, let’s look at the same thing using raw SQL:

class UserController
  @user = ActiveRecord::Base.connection.exec_query('SELECT * FROM users WHERE username = ? AND active = 1', "SQL", [[nil, username]]).first
end

Most Rails developers will be familiar with the first example since it’s fairly standard Rails, but the second example will stand out as being non-idiomatic. I would agree with that, but I think there’s value in understanding why this abstraction is valuable before considering the addition of another abstraction on top of it.

  • It’s more readable than the raw SQL for those who are familiar with SQL and even those who aren’t.
  • It’s reusable since we can extract parts of the query and return a chainable relation. Scopes like active can be extracted to encapsulate common logic like the where(active: true) behavior.
  • It encapsulates domain specific behaviors since it has a full class User backing each result.
  • It’s extensible since it has a “layer” behind the queries, allowing extensions like traces, metrics, N+1 detection, and much more.

Most of these benefits may seem obvious to Rails developers, but this helps show both the benefits of the abstraction, and where the abstraction stops. I want to explore how one more abstraction layered on-top of this SQL abstraction could benefit an application.

One more level of abstraction, curation and encapsulation

We can see the benefits of ActiveRecord’s abstraction of SQL, but what would it look like to have one more layer of abstraction on-top of ActiveRecord that curated those queries and hid them from consumers? Let’s dive into how that abstraction layer could benefit or harm a Rails application using a very simple example.

Let’s take that same, standard Rails example again:

class UserController
  def show
    @user = User.where(profile: params[:username], active: true).first
  end
end

We’re exposing a lot about the underlying data store here. The where implies that it’s SQL, and the profile and active arguments couple this code to the underlying structure of that table everywhere we make this query, or even similar queries. This is usually fine in newer applications but can become problematic as the application grows which I touch on below.

Let’s try to curate that query using an API that roughly implements the repository pattern and see what we might get out of it:

class UserController
  def show
    # returns a User object just like above, retaining
    # the benefits of ActiveRecord's representation of models
    @user = UserRepository.new.user_by_username(params[:username])
  end
end

This extracts the previous abstract concept of fetching a user by their username into a concrete concept. Let’s look at what we gain by curating and encapsulating before we dive into what we’re losing out on:

Pros

  • Documented and Reusable – Now any call site that needs to fetch a user by their username can look for this (hopefully) well documented method and use it without having to worry about implementation details like soft-deletes (active: true).
  • Improve once, improve everywhere – Any improvements to UserRepository#user_by_username apply to all callers. For example, we could put a caching layer in front of it, make it look for active: true users by default, or even change the backing storage mechanism without impacting all consumers of this API.
  • Safer queries – Since all queries go through methods we can ensure that the underlying query is backed by the correct indices, keeping the application fast and the database healthy.
  • Separates Concerns – By extracting queries into an intermediate layer the business logic is separated from the implementation details of fetching the data. The business logic no longer has to know the details of how the data is fetched and can rely on the correct results being returned regardless of what backs that method.

While it’s important to look at the benefits of a solution, it’s even more important to consider the drawbacks and costs:

Cons

  • More initial boilerplate – I think this is true short-term, but long-term I’d hazard to guess you’ll have less code since you can consolidate common code behind methods.
  • Less flexible queries – Hiding your querying functionality behind the repository doesn’t enable one-off queries. You have to modify an existing method or create a new method for each new or modified query.
  • More abstractions – This is yet another abstraction engineers need to learn and understand to build applications.
  • Small productivity sacrifice – The ActiveRecord approach is incredibly productive, and this gives up at least some of that productivity in the short term.

To me, those trade-offs feel worth it and I often wish the Rails app I work on most had this extra layer of abstraction. That Rails app is one of the largest Rails apps in production today, but I don’t think the benefits are limited to large Rails apps, especially since large Rails apps start as small Rails apps. It feels like a small, but impactful design decision that keeps your application flexible with minimal impacts on the experience of writing Rails, including one if it’s mostly highly praised aspects, agility.

Just a thought… for now

One could possibly even describe this abstraction layer as an omakase for data… and jokes aside, the curation and encapsulation of queries and business logic has clear benefits. This is something I’m hoping to explore more, and this post barely scratches the surface. This could be a valuable abstraction for any app, and I’m very interested in hearing the experiences and learnings from other Rails developers implementing similar concepts in their production applications and the benefits they observed.