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.
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:
create - to create a new post.
find_all - to get all posts.
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
Then we can test out 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
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.