See all articles

Design Patterns in Large Rails Applications

Design patterns in large Ruby on Rails web applications: constructing a Query Object class that is responsible for elegantly querying a database. Read our blog post to find out how to make a simple and easy to test Query Object implementation within a Rails application.

Query Objects are classes specifically responsible for handling complex SQL queries, usually with data aggregation and filtering methods, that can be applied to your database. We use this design pattern in large Ruby on Rails web applications, to improve an app’s maintainability and scalability - which makes programmers’ lives a bit easier along the way and allows for faster development times.

Oftentimes, the code responsible for querying the database is placed within scopes in models, or gets mixed in with other logic. It is usually the same code in many places throughout the app, which goes against the principles of DRY programming (Don’t Repeat Yourself!). This leads to a lengthy development process and makes the code more prone to errors - causing a lot of irritation, especially when there is a need to refactor code or make changes to the database itself during any stage of the query.

Example of a poorly designed query function

So what does a poorly designed query function look like and what troubles can it cause? Have a look at the example below - which is a combination of a few actions, including queries to the database - and check for yourself how hard it is to test it without hitting the database with the real records:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 def popular_sport_posts popular_posts = [] if user.posts.where(category: 'sport').any? sport_posts = user.posts.where(category: 'sport') return sport_posts if sport_posts.size == 1 if sport_posts.size > 1 && sport_posts.size < 11 sport_posts.each do |post| popular_posts << post if post.comments.where(published: true).present? end end end if sport_posts.size > 10 sport_posts.each do |post| if post.comments.where(published: true).present? post.comments.each do |comment| if comment.created_at < 30.days.ago popular_posts << post break end end end end end popular_posts end

A best programming practices solution for the above? We can simply extract our database queries in Rails in the above code to create Policy Objects and Query Objects. This will make each section of code more isolated - and thus far easier to test.

Construction of a Query Object class

There is no significant difference between a standard class and a Query Object class. Still, like any other design pattern, this one also has its own specific set of rules.

In terms of initializing the class, it is always clever to pass the scope. If the scope is not given, then we use the default one. Thanks to this approach, we can always pass a scope and use pre-filtered results, or create more complex queries by composing multiple query objects. Here is a simple example:

1 2 3 4 5 6 7 8 9 10 module Products class SportsQuery def initialize(scope = Post.all) @scope = scope end def products scope.where(category: 'sport') end end end

Other benefits of this design pattern

When creating a Query Object class, we make our models slimmer and our logic more decoupled. This means we can create more meaningful and faster tests that focus only on that specific part of the code.

Let’s get back again to the `popular_sport_posts` method presented above and refactor it using a Query Object.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 class SportsQuery def initialize(scope = Post.all) @scope = scope end def sport_posts scope.where(category: 'sport') end def sport_posts_count sport_posts.count end def sport_posts_with_published_comments sport_posts.joins(:comments).where(comments: { published: true }) end def sport_posts_with_recent_published_comments sport_posts_with_published_comments.where('DATE(comments.created_at) > ?', 30.days.ago) end end def popular_sport_posts(user, sports_query = SportsQuery.new(user.posts)) sport_posts_count = sports_query.sport_posts_count if sport_posts_count < 2 sports_query.sport_posts elsif sport_posts_count < 11 sports_query.sport_posts_with_published_comments else sports_query.sport_posts_with_recent_published_comments end end

To test this class we are not forced to create real records in the database in order to check behavior. Tests are much faster and the code itself is far more readable. Thanks to the implemented design pattern, we can effortlessly separate database query logic and then just stub the Query Object, testing database communication in an abstracted test class.

See below for yourself:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 describe '#popular_sport_posts' do context 'when sports posts count is less than 2' do it 'returns sport posts' do posts = [instance_double(Post)] sports_query = instance_double(SportsQuery, sport_posts_count: 1, sport_posts: posts) result = described_object.popular_sport_posts(sports_query) expect(result).to eq(sport_posts) end end context 'when sports posts count is less than 11' do it 'returns sport posts with published comments' do posts = [instance_double(Post)] sports_query = instance_double(SportsQuery, sport_posts_count: 9, sport_posts_with_published_comments: posts) result = described_object.popular_sport_posts(sports_query) expect(result).to eq(posts) end end context 'when sports posts count is more than 10' do it 'returns sports posts with recent published comments' do posts = [instance_double(Post)] sports_query = instance_double(SportsQuery, sport_posts_count: 12, sport_posts_with_recent_published_comments: posts) result = described_object.popular_sport_posts(sports_query) expect(subject).to eq(posts) end end end

Query Object and Builder pattern - the perfect pairing

It is worth mentioning that the Query Object pattern works fantastically with the Builder pattern, especially in cases where a scope object is too complicated to just pass it to the initializer. In such an instance it is better to create a base class, which will expose our code for queries.

Usually we keep our Query Objects under `/lib/query_objects/products/sport_query.rb`, where `products` is the name of the database table and the file name, `sport_query.rb`, is a combination of the query suffix and the type of data we are fetching when using this class.

Intrigued by our solution to construct easy-to-test Query Object classes responsible for elegant communication with your database? Want to check out our IT skills and knowledge in practice? Make sure to get in contact with us at iRonin - we can help you with your Ruby on Rails web applications.

Similar articles

Previous article

GraphQL Part 4 - API with Ruby on Rails