Back to home

Repository Pattern In Rails main image

Repository Pattern In Rails

This post will explore an approach to a repository pattern to be implemented in Ruby on Rails 7.

It is by no mean a perfect implementation - in fact, it is not a "by-the-books" implementation. Ultimately it is just an abstraction so that we have a repository to handle the persistence which in turn also improves our separation of concerns and can help us to make our code more testable.

The blog post will be the first of a two-part blog post series. The follow-up post will focus on implementing the dry-validation gem, hence the name for the repo in this post. It is not necessary to name your Rails project the same as I am doing today.

Source code can be found here.

Prerequisites

  1. Basic familiarity with setting up a new Rails project.
  2. Basic familiarity with RSpec which we will be using for making class doubles of our repository class.

Getting started

We will use Rails to initialize the project demo-repo-pattern-dry-validation:

# Create a new rails project $ rails new demo-repo-pattern-dry-validation $ cd demo-repo-pattern-dry-validation # Make our example Post model $ ./bin/rails g model Post title rating # Create our controller for the example with a create, index and show method $ ./bin/rails g controller posts create index show # Create a folder for our repos $ mkdir -p app/repositories # Add our Post repo $ touch app/repositories/posts_repository.rb # Prepare for our testing $ bundler add rspec-rails --group="development,test" # Install required files $ ./bin/rails g rspec:install # Create file for our testing $ mkdir -p spec/controllers $ touch spec/controllers/posts_controller_spec.rb # Ensure that we create our DB and run our migrations $ ./bin/rails db:create db:migrate # Start the server $ ./bin/rails s

At this stage, our server is configured to run. Given that we are focusing more on the API for this tutorial, we will need to also make an adjustment to our configuration.

Configuring our application

For the demo, we will want to set config.action_controller.default_protect_from_forgery to false.

In the config/application.rb file, update it to the following:

require_relative 'boot' require 'rails/all' # Require the gems listed in Gemfile, including any gems # you've limited to :test, :development, or :production. Bundler.require(*Rails.groups) module DemoRepoPatternDryValidation class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.0 # Configuration for the application, engines, and railties goes here. # # These settings can be overridden in specific environments using the files # in config/environments, which are processed later. # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") config.action_controller.default_protect_from_forgery = false end end

Note: This is purely for demonstration purposes, and should not be set to false in a production environment when CSRF tokens are required.

At this stage, we are ready to start creating our repository and configuring the methods on our posts controller.

Creating our repository class

The idea behind the repository class is to abstract our persistence layer from our controller. This means that any of our calls that need to read or write to the database will be abstracted to our repository class.

In the app/repositories/posts_repository.rb, we will add some methods to help us create a post, get all posts and to get a particular post by its ID:

class PostsRepository class << self def create(title:, rating:) post = Post.new(title: title, rating: rating) post.save! # `save!`` will raise if there is an error, so we assume here # that we were successful and return the post to follow RESTful conventions. # @see https://restfulapi.net/http-status-200-ok/ post end def find_all Post.all end def find_by_id(id:) Post.find_by(id: id) end end end

The above has three functions:

  1. create - to create a new post.
  2. find_all - to get all posts.
  3. find_by_id - to get a particular post by its ID.

It is worth noting that concepts such as validation are omitted from this blog post and will be covered in the next blog post, but for now we are just making the abstraction.

I am purposely using named parameters to enforce more specific invocations of the method, but it is up to you whether you want to make the arguments more generic and modular.

At this point, we are ready to update our controller.

The posts controller

Inside of app/controllers/posts_controller.rb, update the code to the following:

require 'posts_repository' class PostsController < ApplicationController def create post = PostsRepository.create(title: params[:title], rating: params[:rating]) render json: post, status: :ok rescue ActiveRecord::ActiveRecordError render json: { message: 'Internal server error' }, status: :internal_server_error rescue StandardError render json: { message: 'Internal server error' }, status: :internal_server_error end def index posts = PostsRepository.find_all render json: posts, status: :ok rescue ActiveRecord::ActiveRecordError render json: { message: 'Internal server error' }, status: :internal_server_error rescue StandardError render json: { message: 'Internal server error' }, status: :internal_server_error end def show post = PostsRepository.find_by_id(id: params[:id]) render json: post, status: :ok rescue ActiveRecord::RecordNotFound render json: { message: 'Not found' }, status: :not_found rescue ActiveRecord::ActiveRecordError render json: { message: 'Internal server error' }, status: :internal_server_error rescue StandardError render json: { message: 'Internal server error' }, status: :internal_server_error end end

In the code above, we are implementing the three methods create, index and show and using our repository class to make the call to the database.

All errors are being handled by the rescue blocks. In the next post, I will be updating the repository to handle the errors there and returning Success and Failure monads, but for now I am bubbling up the errors directly to our controller and handling them there.

At this point, all we need to do is update our routes configuration.

Updating our routes configuration

Inside of config/routes.rb, update the code to the following:

Rails.application.routes.draw do resources :posts, only: [:index, :show, :create] # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Defines the root path route ("/") # root "articles#index" end

Here we are essentially creating a posts resource that will only allow our index, show and create methods that we have implemented.

Since we set our CSRF requirement to false in our application configuration, I can go ahead and use Postman to test out our API.

First, I can create a new post by sending a POST request to /posts:

Creating a post

Creating a post

Then we can test out fetching all posts:

Fetching all posts

Fetching all posts

Finally, we can test out fetching a particular post by its ID (in our case, by ID 2):

Fetching a specific post by ID

Fetching a specific post by ID

Perfect! It looks like our Rails API system is working with our PostsRespository class!

Testing our controller

The beauty of separating our persistence layer from our controller is that we can test our controller without having to worry about our persistence layer.

In this particular post, I will demonstrate this by using RSpec class doubles to ensure that we are testing all possible cases of our controller for the #index and #show routes.

Note: As previously mentioned, there are posts following on from this that will make adjustments to the tests and how our controller is tested (as well as the persistence layer). Read on after taking a grain of critical salt.

In our spec/controllers/posts_controller_spec.rb, we will add the following:

require 'rails_helper' require 'posts_repository' RSpec.describe PostsController, type: :controller do describe 'GET #index' do context 'successful responses' do it 'returns empty successful array when there are no posts' do stubbed_response = [] posts_respository_klass = class_double(PostsRepository, find_all: stubbed_response).as_stubbed_const expect(posts_respository_klass).to receive(:find_all) get :index expect(response).to have_http_status(:success) expect(response.parsed_body).to eq(stubbed_response) end it 'returns correct array when there are posts' do stubbed_response = [{ id: 1, title: 'Cool post', rating: 'Good' }] posts_respository_klass = class_double(PostsRepository, find_all: stubbed_response).as_stubbed_const expect(posts_respository_klass).to receive(:find_all) get :index expect(response).to have_http_status(:success) expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json)) end end context 'unsuccessful responses' do it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do res = { message: 'Internal server error' } posts_respository_klass = class_double(PostsRepository).as_stubbed_const allow(posts_respository_klass).to receive(:find_all).and_raise(ActiveRecord::ActiveRecordError) expect(posts_respository_klass).to receive(:find_all) get :index expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 500 when there is a StandardError' do res = { message: 'Internal server error' } posts_respository_klass = class_double(PostsRepository).as_stubbed_const allow(posts_respository_klass).to receive(:find_all).and_raise(StandardError) expect(posts_respository_klass).to receive(:find_all) get :index expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end end end describe 'GET #show' do # ... TODO end describe 'POST #create' do # ... TODO end end

In the above, I am separating the tests to describe each route into its own describe block.

After, I am separating the context based on expected successful and unsuccessful responses.

I am using the RSpec class_double method to create a class double that will be used to mock the PostsRepository class. This means that we do not requiring persisting anything to the database, and that we can also now test all of our controller responses to ensure that they are working as expected.

The RSpec mocking also allows us the granularity of setting which error to raise, which you will notice in our #show routes enables us to check for the correct error response based on the error raised.

Testing our #show route

Finally, we can follow a similar pattern to test all the possible scenarios for our #show route.

Note: Here that I am also handling the 404 scenario which makes it more interesting than the above 500-only error responses.

require 'rails_helper' require 'posts_repository' RSpec.describe PostsController, type: :controller do describe 'GET #index' do # ... omitted for brevity end describe 'GET #show' do context 'successful responses' do it 'returns correct array when there is a post with the expected ID' do stubbed_response = { id: 1, title: 'Cool post', rating: 'Good' } posts_respository_klass = class_double(PostsRepository, find_by_id: stubbed_response).as_stubbed_const expect(posts_respository_klass).to receive(:find_by_id).with(id: '1') get :show, params: { id: '1' } expect(response).to have_http_status(:success) expect(response.parsed_body).to eq(JSON.parse(stubbed_response.to_json)) end end context 'unsuccessful responses' do it 'returns 500 when there is an ActiveRecord::ActiveRecordError' do res = { message: 'Internal server error' } posts_respository_klass = class_double(PostsRepository).as_stubbed_const allow(posts_respository_klass).to receive(:find_by_id).and_raise(ActiveRecord::ActiveRecordError) expect(posts_respository_klass).to receive(:find_by_id).with(id: '1') get :show, params: { id: '1' } expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 500 when there is a StandardError' do res = { message: 'Internal server error' } posts_respository_klass = class_double(PostsRepository).as_stubbed_const allow(posts_respository_klass).to receive(:find_by_id).and_raise(StandardError) expect(posts_respository_klass).to receive(:find_by_id).with(id: '1') get :show, params: { id: '1' } expect(response).to have_http_status(:internal_server_error) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end it 'returns 404 when there is no matching post' do res = { message: 'Not found' } posts_respository_klass = class_double(PostsRepository).as_stubbed_const allow(posts_respository_klass).to receive(:find_by_id).and_raise(ActiveRecord::RecordNotFound) expect(posts_respository_klass).to receive(:find_by_id).with(id: '1') get :show, params: { id: '1' } expect(response).to have_http_status(:not_found) expect(response.parsed_body).to eq(JSON.parse(res.to_json)) end end end describe 'POST #create' do # ... omitted end end

Perfect! So now we not only have some specs but those specs also test each identified response from the server and enable us to address them individually.

I will leave it to another post (or you) to implement the #create tests as there will be some refactoring to do once I bring in the monads and our validation.

Summary

Today's post demonstrated how to separate our persistence layer from our business layer in the controller. Separating the persistence layer comes with benefits for testing and a separation of concerns, but it does also mean more bloat in our code. That being said, it means that we can keep a lot of our more custom queries to stay out of the model and be placed into its own class.

In the next post, I will be introducing dry-validation to start adding in more validation as well as cleaning up with dry-monads so that we are handling errors better at the source and more gracefully passing them up to the controller.

Resources and further reading

Photo credit: joelfilip

Personal image

Dennis O'Keeffe

@dennisokeeffe92
  • Melbourne, Australia

Hi, I am a professional Software Engineer. Formerly of Culture Amp, UsabilityHub, Present Company and NightGuru.
I am currently working on workingoutloud.dev, Den Dribbles and LandPad .

1,200+ PEOPLE ALREADY JOINED ❤️️

Get fresh posts + news direct to your inbox.

No spam. We only send you relevant content.