Default Scopes are an Anti-Pattern
Most of you will be familiar with “default scopes” in ActiveRecord, the feature that lets you apply conditions automatically to all queries on a given model. For example, suppose we have a system where a blog author can be deleted (perhaps to remove their access to the blog), while preserving their associated posts. A default scope could then be added to automatically exclude these deleted authors:
The author is “deleted”, but the record (and their corresponding posts) remain. It’s a tempting pattern, because the promise is that you no longer have to remember to ask for only the active authors:
Unfortunately, this scope applies everywhere, even to associations that rely on authors. Using this pattern to hide authors but show posts will cause problems because asking a post for its (deleted) author will now return
nil. To work around this, you have to explicitly unscope
Author before querying:
This completely breaks expectations. As a software author, when I ask a record for an associated object, I never want hidden conditions to be applied to that query–and yet, that’s exactly what default scopes do.
This is why I call default scopes an anti-pattern. According to the definition on Wikipedia, an anti-pattern:
- Is a commonly used pattern of action that appears to be an effective response to a problem, but typically has more bad consequences than beneficial results, and
- Has a good alternative solution that is documented, repeatable and proven to be effective.
“Bad consequences?” Yup. Let’s look at some more, before I address the alternative solution. Consider what happens if I want to see only deleted authors. This won’t work:
Why? That query is actually asking for all authors where
deleted is both true and false! The default scope is still applied. The bandaid, once again, is to explicitly unscope Author first:
The problem here is that
default_scope changes the behavior of model queries in ways that aren’t obvious simply by reading the code. It adds a hidden behavior that betrays expectations.
Author.all now breaks the implicit promise of returning all authors.
Author.where now misleads by promising a certain query, but issuing another. This leads to wasted time hunting for subtle bugs. Bad consequence!
Default Sort Order
Another common use of
default_scope is to apply a global sort condition to a model, like this:
Here, we’re assuming that posts will always be sorted with the most recent first. This seems like another great idea, since that’s how blog articles are almost always listed. Plus, because it’s a default scope, we get both
author.posts sorted just the same!
A minor nitpick with this is that it can obscure the indexes that you need to add for that table. When adding that default scope, it’s easy enough to see that you want to put an index on
created_at, right? And since posts belong to authors, you’ll want an index on
author_id, too. But will you remember that because of that default scope, the
author_id index should include
created_at? If you later decide that posts can be queried by subject, will you remember to include
created_at in that index, too? A default sort order becomes a kind of virus that reaches into queries in potentially surprising ways, and wreaks havoc on your database performance.
A bigger issue, though, is that (just like we saw with the default scope on
Post) the default sort becomes annoyingly persistent. Suppose there is some place in your application where you actually want to sort posts some other way? Well, I hope you like your default sort order, because it will always take precedence.
The results will be sorted first by your default sort condition, and then by any other conditions you want to add. Again, this completely betrays expectations, and the work-around is to remember to add that
unscoped call there. Easy to fix once identified—but often subtle and tricky to troubleshoot. Bad consequence!
The trouble with
unscoped is just this: it ultimately presupposes that you always know in advance exactly what effect the existing default scopes will have on your query, and the more default scopes you have, the more difficult it gets to keep that all straight, especially over months of developing a system.
So, those are the drawbacks. What about a “good alternative solution”? What should we do instead? Use explicit scopes.
Use Explicit Scopes
But that’s more work, right? Actually, it’s not. Being explicit makes your code more readable, understandable, and consequently more maintainable. You’ll save time in the long run. Want all active authors? Add an
active scope, and use it.
Want posts to be sorted by how recent they are? Add an explicit scope:
Is it more characters? Yes, undoubtedly. But it’s far more self-documenting, and far less-surprising, then the implicit behaviors of
default_scope. Is it bug-proof? Heck, no. But I’ve found that leaving an explicit scope off by accident is usually a bug that is quickly noticed and easily fixed, whereas recognizing an over-zealous default scope is generally much more subtle and time-consuming to troubleshoot.
Be kind to your teammates and your future self. Make your code more readable and maintainable. Avoid the