[EN] Domain Driven Rails Architecture
- 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 the changes 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 Bounded
Context 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. This
is done in the config/application.rb
using
Rails::Application
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
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 them first:
- 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.
Setting up context specific test suites is the hardest part (and most
likely 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, but
spec/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 the
domain/spec/rails_helper.rb
and delete everything before RSpec.configure do |config|
.
This is done in order to not load any dependencies (we will load
everything 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
- load test suite dependencies
- create an Application for
rspec-rails
to work (the most fragile piece unfortunately)
- connect to a database
- load domain (shared concerns first since there is no autoloading mechanism)
- load initializer/support files
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
- create an Application for
rspec-rails
and load routes
- load dependencies
- load representation code (shared concerns first since there is no autoloading mechanism)
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.
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: