When thinking about more advanced OOP structure, one can sometimes ask – how to incorporate it into a Rails application? I will try to answer that question in this blog note, introducing some advanced OOP concept into a Rails application (without any architecture gems). The result? Skinny controller, fat business-logic service.
Note: If you haven’t read it, please start with the 101: Advanced OOP structure blog note.
Let’s start with a basic Rails application – I will use a newly created app (I will try to keep my setup minimal so that we can focus on OOP stuff here – therefore no models, etc., only one interesting controller here).
Let’s consider for now that a user can reserve an item – to purchase it later. Think of it as places in theatre or on an airplane. After choosing the item, the user will have 15 minutes to make the payment; otherwise their item will return to the pool of available ones. You can think about it as a simple lock with some expiration time. If the payment has been completed, an item will be marked as sold and will not be placed in the available pool by some other code – outside of the scope of this exercise.
So, let’s create a controller for this:
# app/controllers/reservations_controller.rb
class ReservationsController < ApplicationController
def create
# create new lock if there isn't one for that item
end
end
and add the following to routes.rb
:
# config/routes.rb
# (...)
resources :reservations, only: :create
# (...)
We need a way to store locks and release them after precisely 15 minutes. One can choose to create a new AR model, store the date of creation and select only newer than 15 minutes, while periodically clearing locks older than, e.g., one hour. With a smart construction of indexes and usage of UPSERT
or ON CONFLICT UPDATE
that would be a good approach. Other may use Redis as the lock provider; Redis is a NoSQL database natively supporting locks (even distributed ones).
However, I decided to mock my lock provider. That way I can keep the scope of this blog note as close to OOP as possible, without drifting too much into the (otherwise fascinating!) world of locking algorithms and techniques.
So, my imagination-lock (let’s think about it as a gateway to some service on same server providing locks) will have the following public interface:
* command=(command_string) # send connection query to a server
# currently it can be only `LOCK`
* argument_1=(argument_value) # sets first argument to command
# for lock it's user_id
* argument_2=(argument_value) # sets first argument to command
# for lock it's item_id
* execute_command # execute the command with provided arguments
# returns 0 if locking failed, and 1 if it succeeded
Please take note, that this API is complicated on purpose – to better illustrate the difference object-oriented refactoring will make here.
I will be using this mock code for this:
# lib/lock.rb
class Lock
attr_accessor :command, :argument_1, :argument_2
def execute_command
if !argument_1 || !argument_2 || command != 'LOCK'
raise ArgumentError, 'Bad arguments provided'
end
[0, 1].sample # hey, external API can always fail!
end
end
Using our library, let’s write a basic implementation in our controller:
# app/controllers/reservations_controller.rb
require Rails.root.join('lib/lock')
class ReservationsController < ApplicationController
skip_before_action :verify_authenticity_token
# we're trying to make the example as simple as possible
rescue_from ArgumentError, with: :argument_error
# but some error handling would be good ;)
def create
lock = Lock.new
lock.command = 'LOCK'
lock.argument_1 = params[:user_id]
# in production it should be some kind of current_user
lock.argument_2 = params[:item_id]
if lock.execute_command == 1
render json: {}, status: :created
else
render json: {}, status: :conflict
end
end
private
def argument_error
render json: { error: 'Bad arguments' }, status: :bad_request
end
end
Every experienced developer should now get that itch in the back of their head – there is something wrong with the code. The create
method has too many lines for a controller. It contains a lot of logic, especially for a controller. A controller’s responsibility should be to extract params, call another layer and return the result. This code could use more abstraction. Testing it also will be painful – we probably will have to mock some Lock
calls. But what if we can make it easier? What if we can introduce some abstraction that will make testing this stack a breeze?
Let’s try it!
First – we will be adding new layers to our Rails app. I think services
would be sufficient here. Start by creating our first service and moving most of the controller code there:
# app/services/lock_service.rb
require Rails.root.join('lib/lock')
class LockService
def initialize(user_id:, item_id:)
@user_id = user_id
@item_id = item_id
end
def call
lock.command = 'LOCK'
lock.argument_1 = user_id
lock.argument_2 = item_id
lock.execute_command == 1
end
private
attr_reader :user_id, :item_id
def lock
@lock ||= Lock.new
end
end
and our controller will now looks a lot better:
# app/controllers/reservations_controller.rb
class ReservationsController < ApplicationController
skip_before_action :verify_authenticity_token
rescue_from ArgumentError, with: :argument_error
def create
if lock_service.call
render json: {}, status: :created
else
render json: {}, status: :conflict
end
end
private
def argument_error
render json: { error: 'Bad arguments' }, status: :bad_request
end
def lock_service
@lock_service ||= LockService.new(user_id: user_id, item_id: item_id)
end
def user_id
@user_id ||= params[:user_id]
end
def item_id
@item_id ||= params[:item_id]
end
end
Now the controller finally looks like something that would pass code review! How about testing it? Unfortunately doing DI for controllers is particularly hard in Rails, probably because DHH doesn’t believe in DI. But we will find a way!
First of all, let’s create a boilerplate class that will work as a singleton (i.e., there will be only one Registry
object in our system) registry for our services, register our LockService
and freeze it for all changes for every env (excluding test
of course):
# config/initializers/service_provider.rb
class ServiceProvider
@services = {}
def self.register(key, klass)
return false if @services.key?(key) && !Rails.env.test?
@services[key] = klass
end
def self.get(key)
@services[key]
end
def self.[](key)
get(key)
end
def self.finished_loading
@services.freeze unless Rails.env.test?
end
end
ServiceProvider.register :lock_service, LockService
ServiceProvider.finished_loading
and of course, use it in the controller:
# app/controllers/reservations_controller.rb
class ReservationsController < ApplicationController
skip_before_action :verify_authenticity_token
rescue_from ArgumentError, with: :argument_error
def create
if lock_service.call
render json: {}, status: :created
else
render json: {}, status: :conflict
end
end
private
def argument_error
render json: { error: 'Bad arguments' }, status: :bad_request
end
def lock_service_class
@lock_service_class ||= ServiceProvider.get(:lock_service)
end
def lock_service
@lock_service ||= lock_service_class.new(user_id: user_id, item_id: item_id)
end
def user_id
@user_id ||= params[:user_id]
end
def item_id
@item_id ||= params[:item_id]
end
end
Please take note that in a typical Rails project, you would probably instead write the lock_class
method as simply returning a LockService
and mock it in the test instead. I, however, wanted to explicitly have all the class relationships specified and avoid using monkey-mocking (my term similar to monkey patching, means reopening some class or object to change the behavior of some methods, may be done using an external library that is hiding it from us). Also – this is an article about object-oriented programming, so we should use dependency injection instead of monkey-mocking.
Ok, so we got a way to inject a mock for the LockService
class in our controller – time to test it:
# test/controller/reservations_controller_test.rb
require 'test_helper'
class ReservationsControllerControllerTest < ActionDispatch::IntegrationTest
SuccesfullLockService = Struct.new(:user_id, :item_id, keyword_init: true) do
def call
true
end
end
FailedLockService = Struct.new(:user_id, :item_id, keyword_init: true) do
def call
false
end
end
test 'returns HTTP bad_request when missing user_id' do
ServiceProvider.register(:lock_service, LockService)
post reservations_url, params: { item_id: 2 }
assert_response :bad_request
end
test 'returns a JSON error when missing user_id' do
ServiceProvider.register(:lock_service, LockService)
post reservations_url, params: { item_id: 2 }
assert_equal({ 'error' => 'Bad arguments' }, response.parsed_body)
end
test 'returns HTTP bad_request when missing item_id' do
ServiceProvider.register(:lock_service, LockService)
post reservations_url, params: { user_id: 2 }
assert_response :bad_request
end
test 'returns a JSON error when missing item_id' do
ServiceProvider.register(:lock_service, LockService)
post reservations_url, params: { user_id: 1 }
assert_equal({ 'error' => 'Bad arguments' }, response.parsed_body)
end
test 'returns emtpy JSON when service returns true' do
ServiceProvider.register(:lock_service, SuccesfullLockService)
post reservations_url, params: { user_id: 1, item_id: 2 }
assert_equal({}, response.parsed_body)
end
test 'returns HTTP created when service returns true' do
ServiceProvider.register(:lock_service, SuccesfullLockService)
post reservations_url, params: { user_id: 1, item_id: 2 }
assert_response :created
end
test 'returns empty JSON when service returns false' do
ServiceProvider.register(:lock_service, FailedLockService)
post reservations_url, params: { user_id: 1, item_id: 2 }
assert_equal({}, response.parsed_body)
end
test 'returns HTTP conflict when service returns false' do
ServiceProvider.register(:lock_service, FailedLockService)
post reservations_url, params: { user_id: 1, item_id: 2 }
assert_response :conflict
end
teardown do
ServiceProvider.register(:lock_service, LockService)
end
end
We provide two mocked lock services – one always returning false
and one always returning true
. Then we test the HTTP response codes. Also, we test whether the controller is handling the case of missing params correctly. Please take a note that since we’re injecting mocks into the controller, we need to re-inject the original dependency after each test – that is what teardown
does.
Please take note that while this approach should be working correctly, it may fail when running the tests in parallel – manipulating a class level instance variable is not thread-safe. In normal operation, it will be all preloaded in an initializer and frozen, so there will be no manipulation of services at runtime; hence it will work correctly in, for example, multithreaded Puma application server.
Ok, so we’ve got the controller part quite right, time to move onto LockService
tests. Let’s add a sparkle of DI there:
# app/services/lock_service.rb
require Rails.root.join('lib/lock')
class LockService
def initialize(user_id:, item_id:, lock: Lock.new)
@user_id = user_id
@item_id = item_id
@lock = lock
# on a side note - that would fail miserably in Python
# do you know why?
end
def call
lock.command = 'LOCK'
lock.argument_1 = user_id
lock.argument_2 = item_id
lock.execute_command == 1
end
private
attr_reader :user_id, :item_id, :lock
end
while providing it with some struct as a mock, we can quickly test this PORO (plain old Ruby object):
require 'test_helper'
class LockServiceTest < ActiveSupport::TestCase
MockedSuccessfulLock = Struct.new(:command, :argument_1, :argument_2) do
def execute_command
1
end
def inspect_fields
[command, argument_1, argument_2]
end
end
MockedFailedLock = Struct.new(:command, :argument_1, :argument_2) do
def execute_command
0
end
def inspect_fields
[command, argument_1, argument_2]
end
end
test 'sets fields correctly when calling LockService' do
lock_object = MockedSuccessfulLock.new
service = LockService.new(user_id: 1, item_id: 2, lock: lock_object)
service.call
assert_equal ['LOCK', 1, 2], lock_object.inspect_fields
end
test 'returns false when Lock returns 0' do
lock_object = MockedFailedLock.new
service = LockService.new(user_id: 1, item_id: 2, lock: lock_object)
assert !service.call
end
test 'returns true when Lock returns 1' do
lock_object = MockedSuccessfulLock.new
service = LockService.new(user_id: 1, item_id: 2, lock: lock_object)
assert service.call
end
end
In conclusion – we’ve started with the controller that was doing all the work. We’ve created an additional layer of abstraction inside our Rails application, which in turn resulted in much more readable and testable code – all that while doing a real-life task inside the Rails application. Nifty!
As always, you can find the example application at our GitHub.