Ruby on Rails Architecture
Most of our projects are large and are being developed for multiple years. Rails Way™ isn’t the best for large codebases because it increases development time and complexity:
- Extending or refactoring code is harder and more time-consuming.
- Writing automated tests is more complicated.
- Introducing new developers to the project will take longer time.
To overcome those issues we use improved code structure since the start of the project.
We decouple business logic from the framework by putting all app-specific code inside app/domains
folder.
The business logic is split by domain first (domains can/should also be split into subdomains) and only then by technology (i.e., interactors, forms…).
We distinguish two types of domains:
- Bottom-level. It is the rightmost folder in a folder structure. For example, in
admin/posts/
, the bottom-level domain would beposts/
. - Higher-level. That would be any domain except the bottom-level one. For example, in
webapp/admin/posts/
,webapp/
andadmin/
would be higher-level domains.
The rules for domain naming are as follows:
- Bottom-level domain name usually is the same as of a corresponding model (and/or the controller).
- Higher-level domain names mimic controller namespacing (so if you have a controller named
Admin::PostsController
, then you will have domain folder structure asadmin/posts/
). - If the domain is not associated with a controller (e.g., shared code) then it can be named and structured according to function. It doesn’t always have technologies.
Inside the domain folder, we structure code by technology and have the following directories:
- Interactors at
interactors/
. Ideally, they should be named after a CRUD action they represent. Controller’s action should only call a corresponding interactor inside its domain. - Decorators at
decorators/
. - Form objects at
forms/
. - Policy objects at
policies/
. - Query objects at
queries/
. - View objects at
presenters/
. - Value objects at
values/
. - Background jobs at
jobs/
.
Every domain has a domain interface module (in DDD terms - aggregate root).
Domain interface module has the same name as the domain itself (but if there is a need, you can name the domain interface module differently).
It is placed just outside the domain (because this way complies with Rails file loading mechanism).
So, the interface module for domain admin/posts
would be placed inside admin/posts.rb
file.
For example, let’s say we have a Create
interactor inside admin/posts
domain.
The domain interface module and controller would be something like this:
# app/domains/admin/posts.rb
module Admin
module Posts
include ExposableDomain
expose :create
end
end
# app/controllers/admin/posts_controller.rb
module Admin
class PostsController < ApplicationController
def create
result = Posts.create(params)
...
end
end
end
The interface module shown above would be equivalent to this:
# app/domains/admin/posts.rb
module Admin
module Posts
def self.create(params)
Interactors::Create.call(params)
end
end
end
Using a domain interface module has the following benefits:
- Only one entry point (a module) to a domain so it is easier to control the domain’s state.
- It is easy to hide the structure of a domain when only the domain interface module knows about it.
Also:
- Domains should be as isolated from each other as possible and only have one entry point (i.e., domain interface module).
lib/
folder contains only code that might be gemified in the future (or code that is not app-specific).- Code, which is shared among domains and/or is app-specific, might be placed inside lowest common ancestor domain.
Controller microarchitecture
- Avoid instance variables in controllers. Use, e.g., decent_exposure instead.
- When possible, try calling before and after functionality explicitly (somewhere inside the interactor or child object) instead of using Rails filters.
- For simple cases, strong parameters can be used but form objects should be preferred instead.
- Actions that are not CRUD are avoided as much as possible.
- Action delegates parameters to an interactor and renders response by its returned value. Nothing more.
- Might have gem related code.
Using another domain
If you want to use other domain functionality, you should use its interface module.
For example, if we have Create
interactor inside clients/interactors/
and we want to use bank_accounts
domain functionality, then we can do it like this:
# app/domains/bank_accounts.rb
module BankAccounts
include ExposableDomain
expose :create, :update
def self.supported_currencies
# return the list supported currencies for this account
end
end
# app/domains/client/interactors/create.rb
module Clients
class Create < Interactor
def call(params)
result = BankAccount.create(params)
...
currencies = BankAccount.supported_currencies
...
end
end
end
Background jobs
- Background jobs are placed inside the domain, preferably inside
<domain>/jobs/
folder. - Recurring jobs should be put inside the domain, with which it is associated.
Serializers
- Serializers are placed inside
app/serializers
(since serializers usually use other serializers, it would be inconvenient to place them inside domain folder). - Serializers should have the same folders as the controller (or API) from where they are used.
Model microarchitecture
- Should hold only associations, constants, simple queries (but prefer query objects) and gem related code.
Model callbacks
Avoid using callbacks in models except for cases that are necessary for storing data. Instead, a decorated object can be created that adds the needed functionality. Alternatively, model callbacks can be substituted with observers. Callbacks should only be used when the code depends on ActiveRecord persistence state (e.g. callbacks that must be run when transaction is commited).
Problems with model callbacks:
- Maintaining and extending models is hard. Putting functionality in callbacks may violate single responsibility principle.
- Extra work needed when writing tests. Callbacks must be taken into account when testing. Appropriate stubs, mocks, and fixtures must be placed for tests to pass. Modifying a single callback can break a lot of tests.
View microarchitecture
- There should be no calculations only object method calls and helpers (but prefer not to use them).
- There should be no instance variables (use, e.g., decent_exposure instead).
- View should be split to partials when necessary to highlight structure.
- All variables inside a partial should be passed when rendering it.
Helpers
Try not to use them at all. It is an anti-pattern. View objects should be used instead.
Concerns
We do not use concerns.