- Time: 30-40 min
- Level: Intermediate/Advanced
- Code: GitHub
- References:
The following article will describe an architecture of a Rails application that is a combination of ideas from the referenced articles as well as a few additional tools to monitor the quality of the code. Main requirements for the application are:
- Separation of view(representation) and business logic(your domain)
- Separation of dependencies(gems) and as a result ability to run unit tests in isolation
- Solution has to be simple and straightforward (Rails is awesome and we’re not going to fight with it)
TLDR - Github repo and a commit with all thechanges applied to a fresh Rails application
Separation of representation and domain
The first thing to do is to have clear separation of representation and business logic in the folder structure (and in your head). In order to achieve this we will introduce a new folder called representations
and put everything we need to represent domain entities there. In the example those were:
-
representations/
assets/
controllers/
decorators/
public/
views/
vendor/
routes.rb
I personally prefer using decorators instead of helpers so there is no
helpers/
folder.
Next we will setup a folder structure for your domain entities and logic - none of those two should be a part of a representation layer. In order to do so let’s create a new folder domain/
and move our models and database configuration there:
-
domain/
contexts/
database.yml
The name contexts/
here is a reference to BoundedContext pattern in a DDD theory, you may name it differently and have any folder structure within it.
Now we need to make Rails aware of a change in a folder structure. Th isis done in the config/application.rb
usingRails::Application
# config/application.rb
28 paths['app/assets'] = 'representations/assets'
29 paths['app/views'] = 'representations/views'
30 paths['config/routes.rb'] = 'representations/routes.rb'
31 paths['config/database'] = 'domain/database.yml'
32 paths['public'] = 'representations/public'
33 paths['public/javascripts'] = 'representations/public/javascripts'
34 paths['public/stylesheets'] = 'representations/public/stylesheets'
35 paths['vendor'] = 'representations/vendor'
36 paths['vendor/assets'] = 'representations/vendor/assets'
37 # impacts where Rails will look for an ApplicationController and ApplicationRecord
38 paths['app/controllers'] = 'representations/controllers'
39 paths['app/models'] = 'domain/contexts'
40
41 %W[
42 #{ File.expand_path( '../representations/concerns', __dir__ ) }
43 #{ File.expand_path( '../representations/controllers', __dir__ ) }
44 #{ File.expand_path( '../domain/concerns', __dir__ ) }
45 #{ File.expand_path( '../domain/contexts', __dir__ ) }
46 ].each do |path|
47 config.autoload_paths << path
48 config.eager_load_paths << path
49 end
After this change Rails will work with new layout as if the original one was never changed - autoloading, eager loading, testing, asset compilation - all are fully functional.
I personally believe that having
ApplicationController
andApplicationRecord
asConcerns
improves flexibility of the code, so in the provided example they are concerns and there is an additionalconfig/initializers/draper.rb
file to make Draper work
# config/initializers/draper.rb
3 DraperBaseController = Class.new( ActionController::Base )
4 DraperBaseController.include( ApplicationController )
5
6 Draper.configure do |config|
7 config.default_controller = DraperBaseController
8 end
Separation of environments and building independent test suites
Since we’ve separated the representation and domain having separate test suite for each would be beneficial - properly implemented those suites would be faster, isolated and independent. Let’s prepare environments for themfirst:
- Introduce separate
Gemfile
andGemfile.lock
for representations and domain - Make main
Gemfile
use ones we add on the previous step - Setup independent test environments for
representations/
anddomain/
Adding more Gemfiles
is trivial - just create new file and extract dependencies from the main Gemfile
.
Make main Gemfile
aware of additional dependencies is quite simple too -bundler
already has a method to load additional files, bundler
will complain if there are any issues as if the Gemfile
has never been split.
# Gemfile
54 %w[representations/Gemfile domain/Gemfile].each do |custom_gemfile|
55 eval_gemfile custom_gemfile
56 end
Setting up context specific test suites is the hardest part (and mostlikely it will introduce more issues as application grows). As a first step we run rspec --init
within representations/
and domain/
, as a result new representations/spec
and doman/spec
folders will be created.
spec/spec_helper.rb
file will be added automatically too, butspec/rails_helper.rb
won’t and we have to add and configure it manually.
Domain test suite configuration
To start with we will copy the spec/rails_helper.rb
to thedomain/spec/rails_helper.rb
and delete everything before RSpec.configure do |config|
.This is done in order to not load any dependencies (we will loadeverything we need later). We won’t be able to run tests at this point, but that’s only a first step
Next we actually make sure we load only things we actually need:
- load
active_record
andrspec-rails
# domain/spec/rails_helper.rb
3 require 'active_record/railtie'
4 require 'active_support'
5 require 'rspec/rails'
- load test suite dependencies
7 ENV['RAILS_ENV'] ||= 'test'
8 require 'spec_helper'
9 require 'database_cleaner'
10 require 'factory_bot'
11 require 'pry-byebug'
- create an Application for
rspec-rails
to work (the most fragile piece unfortunately)
13 ContextsTestApplication = Class.new( ::Rails::Application )
14 ::Rails.application = ContextsTestApplication.new
- connect to a database
16 database_configurations = YAML.load(
17 ERB.new(
18 File.read( File.expand_path( '../database.yml', __dir__ ) )
19 ).result
20 )
21
22 ActiveRecord::Base.establish_connection( database_configurations['test'] )
23
- load domain (shared concerns first since there is no autoloadingmechanism)
24 %w[concerns contexts].each do |folder|
25 Dir[File.expand_path( "../#{folder}/**/*.rb", __dir__ )].each { |f| require f }
26 end
- load initializer/support files
28 Dir['./spec/support/*.rb'].each { |f| require f }
29
30 RSpec.configure do |config|
Representations test suite configuration
Setting up the test suite for representations is quite similar, the only difference is things we load:
- load
action_controller
andrspec-rails
# representations/spec/rails_helper.rb
3 require 'action_controller/railtie'
4 require 'active_support'
5 require 'rspec/rails'
6 require 'spec_helper'
- create an Application for
rspec-rails
and load routes
8 RepresentationsTestApplication = Class.new( ::Rails::Application )
9 ::Rails.application = RepresentationsTestApplication.new
10 require_relative '../routes'
- load dependencies
12 require 'pry-byebug'
13 require 'uuid'
- load representation code (shared concerns first since there is no autoloading mechanism)
15 %w[concerns controllers decorators].each do |folder|
16 Dir[File.expand_path( "../#{folder}/**/*.rb", __dir__ )].each { |f| require f }
17 end
Now we have two independent test suites that:
- may include only unit tests
- force you to stay within your context
- load/reload environment fast (files took 2.65 seconds to load if run from main app and only 0.96642 seconds to load if run independently)
Since test suite files are within
representations/
anddomain/
folders they can’t be within theapp/
folder - Rails will try to eager load all files in those folders in production
Final parts
As I’ve mentioned in my previous post, I believe that tests within top level spec/
and test/
folders should not be unit tests and always test multiple components of the application. Opposite applies to tests within repreentations/spec
and domain/spec
- those always should be unit tests.
One issue with this setup is that in order to be able to execute tests within context-specific environment you have to have separate Gemfile.lock
files, which may result in different gem versions used when you run tests in isolation and as a whole suite. Let’s introduce a test to make sure we get a notification if such situation happens.
# spec/sanity/gemfile_spec.rb
5 RSpec.describe 'Gemfile' do
6 context 'Domain Gemfile' do
7 it 'have gems locked at the same version as a global Gemfile' do
8 global_environment = Bundler::Dsl.evaluate( 'Gemfile', 'Gemfile.lock', {} )
9 .resolve
10 .to_hash
11 local_environment = Bundler::Dsl.evaluate( 'domain/Gemfile', 'domain/Gemfile.lock', {} )
12 .resolve
13 .to_hash
14
15 diff = local_environment.reject do |gem, specifications|
16 global_environment[gem].map( &:version ).uniq == specifications.map( &:version ).uniq
17 end
18
19 expect( diff.keys ).to eq( [] )
20 end
21 end
The example application also includes Git hooks that will be installed into your application if you run ./bin/setup
and will be automatically executed before and after you commit.
Before hook runs Rubocop against changes staged for commit, after hook allows you to run rails_best_practices, reek, brakeman and mutant against your code
Summary
I like a lot the flexibility that this architecture provides - if needed you can isolate any part of your code and threat it as a standalone unit. At the same time it uses Rails API, so it’s not against it -rather it’s a yet another way to organize your code.I’m eager to try it with more complex and legacy applications -introducing new architecture is quite simple in both cases.
Links:
Top comments (0)