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 be posts/.
  • Higher-level. That would be any domain except the bottom-level one. For example, in webapp/admin/posts/, webapp/ and admin/ 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 as admin/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:

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.

Some caveats

  • Never use a class as a namespace. You can have inner classes, that is fine. See this post for more info.
  • Using compact style for ruby module nesting (i.e., module A::B; end instead of module A; module B; end; end) may resolve in unexpected behavior. See this post for more info.