Domain Driven Design approach in Ruby On Rails
This article is aimed at developers already familiar with Domain Driven Design who want to apply its principles to the most famous web framework in Ruby.
REQUIREMENTS
- Ruby On Rails version 6 (ROM Rails gem does not yet support version 7)
- Ruby On Rails installation without Active Record
- Committing heresey and breaking away from Rails conventions
- Cup of coffee or tea
No Active Record No Cry
Ruby On Rails is an amazing piece of software that solves a lot of common problems which enables us to be more productive and have a more enjoyable experience while developing our apps. We don't need to be concerned with "plumbing work" as I like to call it, that's already done for us by DHH and the RoR team. In my opinion, it is a full-stack framework that has the best integration with the Javascript ecosystem and many drew inspiration from it, Symfony and Laravel for that matter. I mean Laravel is basically Ruby On Rails just in PHP, it's a carbon copy in general, to be fair.
But there are two disadvantages to Ruby On Rails if you want to go the DDD way:
- Active Record pattern
- Absence of a dependency injection container (I recommend reading articles by Martin Fowler and Fabien Potencier if you want to freshen up on this topic)
Active Record pattern is not the best tool for the job if you want to apply Domain Driven Design, it's not a dealbreaker, there are ways to deal with it, but in it's core it goes contratry to the Domain Driven Design principles. Entities shouldn't be concerned with their storage mechanism and Active Record models are in a way "god classes" which are both the Entity and the implementation of the Repository speaking in DDD terms. An additional issue is that they map 1:1 with database tables and in the real world our models can be made up from multiple tables or data sources for that matter. If we go even further the fact that they interact with the database tells us that they can't be tested in isolation. Active record is a good fit for rapid development and CRUD centric applications, so why battle the framework and make our lives harder than necessary when we have an amazing gem that implements the Data Mapper Pattern ROM - Ruby Object Mapper. That is the first piece of the puzzle that I'm going to cover in this article, but before we go into more details how to set it up, let's speak a bit about the second disadvantage.
Absence of a DI container is not really Ruby On Rails' fault, it has to do more with the nature and philosophy of Ruby as a language, the need for dependency injection containers hasn't really evolved organically. Ruby is a dynamic language through and through, it allows you to add runtime definitions to your classes and many more meta-programming "stuff" and until recently with the introduction of RBS - Ruby Type System it had no notion of an Interface at the language level, so naturally DI containers were not something rubyists where loosing sleep about.
Nonetheless, some fine Rubyists saw the benefits of dependency injection containers and Inversion Of Control principle and gave us the second piece of the puzzle, the Dry Container and Dry Auto Inject gems. The DI container implementation is a bit clunky to me, coming mostly from Symfony and Laravel background, but that is not a diss at the authors, not at all, it's just a Ruby thing again, maybe I'm not used to it yet. It will probably grow on me with time.
But first things first, before we do anything else, we want to make our code as strict as possible, shocking! Ruby and strict code, you don't hear that very often now do you? We're going to be adding the RBS Rails gem which will give us Rails type definitions in addition to the Ruby standard library ones and we're going to be adding the Steep gem which is a static analysis tool.
Oh, almost forgot, to create a new Ruby on Rails project without active record just run rails _6.0.3.4_ new my_rails_app --skip-active-record
and to remove Active Record from an existing project you need to remove or comment out every config that starts with config.active_record
or config.active_storage
and replace require 'rails-all'
in your config/application.rb
with something like this:
require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
require "active_job/railtie"
# require "active_record/railtie"
# require "active_storage/engine"
require "action_controller/railtie"
require "action_mailer/railtie"
# require "action_mailbox/engine"
# require "action_text/engine"
require "action_view/railtie"
require "action_cable/engine"
require "sprockets/railtie"
# require "rails/test_unit/railtie"
Setting up RBS and Steep
Open your Gemfile and add the following gems:
# RBS and static analysis
gem 'rbs_rails', require: false
gem 'steep'
Then run bundle install
. *Elevator lift music*
Now that you've installed the gems we need to run the following commands to fetch all the Rails types by using RBS Collections, wait what? Now what is that? Well, RBS Collections bundle all the types outside of the Ruby language standard library, so all of the gems' types, something like Typescript's definition npm packages. If you want to read up more about how and what RBS Collections does I recommend reading this article from the author of both RBS and RBS Collections.
Next step is bundle exec rbs collection init
- this will create a rbs_collection.yaml
configuration file which is set up by default, you don't really have to change anything in it, but since we're adding the Steep gem we need to add two lines:
# Download sources
sources:
- name: ruby/gem_rbs_collection
remote: https://github.com/ruby/gem_rbs_collection.git
revision: main
repo_dir: gems
# A directory to install the downloaded RBSs
path: .gem_rbs_collection
gems:
# Skip loading rbs gem's RBS.
# It's unnecessary if you don't use rbs as a library.
- name: rbs
ignore: true
- name: steep # <-- Add this
ignore: true
The configuration we added will just prevent RBS Collections from loading the types of the Steep gem since that one already ships with them so you would get a duplicate type definition error.
Now you can install the types by running bundle exec rbs collection install
, perfect. The last thing left to do is run steep init
to generate the Steepfile which should look like this for a Rails setup:
target :app do
signature 'sig'
check 'src'
check 'lib'
check 'app'
check 'Gemfile'
configure_code_diagnostics(Steep::Diagnostic::Ruby.strict) # `strict` diagnostics setting
configure_code_diagnostics do |hash|
hash[Steep::Diagnostic::Ruby::NoMethod] = :information
end
end
target :test do
signature 'sig'
check 'test'
end
RBS Rails has three commands:
rbs_rails:generate_rbs_for_models
- generates type files for Active Record Models but since we are going to be using a different ORM this one is not important to us.rbs_rails:generate_rbs_for_path_helpers
- generates type files for Ruby On Rails helpers and.rbs_rails:all
- runs both of the aforementioned.
To generate type files for the custom code you add you are going to be running rbs prototype command, in our case, it's going to be:rbs prototype rb --out-dir=sig/ --base-dir=./ app/
Let's break it down so we get a better understanding of what's happening:
rb
- what type files we want to generate rb is for RBS, rbi is for Sorbet files.--out-dir=sig/
- the directory where we want to store the type files, by default and convention it is sig/ folder.--base-dir=./
- the base directory to be used when mimicking the folder structure in the sig/ folder (in this case it's going to be the app/ directory that's provided as the next parameter).app/
- directory which holds the files we want to generate the types for.
We can also generate types on a file basis as well rbs prototype rb a_ruby_file.rb
. I bet you're getting restless of all this reading so run the command rbs prototype rb --out-dir=sig/ --base-dir=./ app/
and see what happens. *Elevator music again*
The sig/ folder is now full of rbs type files which will have untyped
type for params and return values which you will have to update manually and whose validity you can check by running steep check
.
Ruby Object Mapper
I think you are able to guess at this point what our next step is going to be...correct, we are going to update the Gemfile once again and run bundle install
. *Elevator lift music*
gem 'dotenv-rails', groups: [:production, :development, :test] # <-- Add this one at the top of the file just below ruby version declaration
# Ruby Object mapper
gem 'rom-sql'
gem 'rom-rails'
gem 'rom-repository'
The Dotenv gem is optional but it is convenient, it allows us to load our database configurations from a .env file and it gives us the flexibility to be able to easily swap it out or have different database configurations per environment. That being said, to hook up Ruby Object Mapper with Rails we need to add an initializer file config/initializers/rom.rb:
ROM::Rails::Railtie.configure do |config|
config.gateways[:default] = [:sql,
ENV.fetch('DATABASE_URL'), not_inferrable_relations: [:schema_migrations]
]
config.auto_registration_paths << 'src/infra/persistence'
#config.auto_registration_paths = [{ path: 'src/infra/persistence', namespace: 'Infra::Persistence' }]
end
and update the Rakefile to get the migration cli commands:
require 'rom/sql/rake_task'
We have now successfully replaced Rails' Active Record model objects with ROM's Relation objects, they are similar in their purpose but still quite different in the way they are implemented and used.
Relation objects define the relations of our domain model with the Persistence layer - our database. They hold the table definitions, association/relationship definitions with other Relation objects much like the Active Record model, you can also directly interact with the database through them but unlike Active Record models when you query the database they won't be returning instances of themselves, they will be either returning hash maps or ROM Structs, which are plain Entity objects completely decoupled from their data source. If you ask me we shouldn't be really querying the database directly with them, that's eerily similar to the Active Record pattern, we should be using the ROM Repositories. For a more in-depth comparison, you can read this article in their docs.
By default, the Rom Rails gem autoloads relations and commands from the app/{relations,commands}
folders, but we don't want that, and that's why we added this line to our initializer file config.auto_registration_paths << 'src/infra/persistence'
. Unfortunately, there is a small caveat to this, currently, Rom Rails only supports autoloading non-namespaced classes, to be able to autoload a namespaced class like I'm going to be using in my examples you will have to either use my fork of the gem or wait until the PR I made to solve the issue is merged. If you're fine with using my gem, use the commented-out lines in the examples above instead of the existing ones. As it happens the PR has been merged and I have now become the new maintainer of the gem, so there's that.
To illustrate the differences let's create the necessary components to be able to fetch and store entities:
1. Relation
module Infra
module Persistence
module Relations
class Users < ROM::Relation[:sql]
schema :users do
attribute :id, ROM::Types::Integer
attribute :name, ROM::Types::String
attribute :age, ROM::Types::Integer
primary_key :id
end
end
end
end
2. Repository
module Infra
module Persistence
module Repository
class Users < ROM::Repository[:users]
struct_namespace Domain::Entity
commands :create, update: :by_pk, delete: :by_pk
def find_by_id!(id)
users.by_pk(id).one!
end
def create_with_tasks(user)
users.command(:create).call(user)
end
end
end
end
end
I won't go further into details about Ruby Object Mapper at this point, you can scavenge their docs, I might update this article with some examples further down the road as I get to play with the library a bit more myself.
Dependency Injection Container
Remember that part about heresy? Well, as I was saying, we're going to be doing things a bit differently he he.. We're not going to be putting our code into app/
or lib/
as one might expect (I mean lib is short of library so we shouldn't be putting it there for sure, I don't know why that was a thing for some time in Rails). For us DDDers framework is infrastructure, just a mechanism for delivery of our domain code which powers our business, so let's not muddle it!
We are going to add a src/
folder with the Holy Trinity of subfolders: src/domain/
, src/app/
and src/infra/
. We still want to be able to leverage the benefits of Rails autoloader and in order for it to work we need to tweak the config, so add the following line in config/application.rb:
require_relative "boot"
require "rails/all"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)
module RailsDataMapper
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 6.1
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
#
config.autoload_paths << "#{root}/src" # <--- add this line
end
end
Now that we have this uncomfortable part out of our way, add the following gems to Gemfile and once again run bundle install
. *Elevator lift music*
# Dependency Injection
gem 'dry-container'
gem 'dry-auto_inject'
These two gems are the main components of Dry container, the actual container, and the autowiring service. We are not going to be using them directly, we are going to add our own little abstractions. This can be done in a million different ways and the library authors' were also kind enough to give us examples in the comments of their code, but I choose to do it in this particular way, which doesn't mean it won't get changed or updated in the future.
The goal with the dependency container is to have one source of truth which will build our services for us and automatically inject them into the places where we actually use them so let's add another folder (to satisfy Rails autoloading conventions) and two files, namely:
1. src/infra/dependency/container.rb
module Infra
module Dependency
class Container
include Dry::Container::Mixin
end
end
end
2. src/infra/dependency/autowire.rb
module Infra
module Dependency
class AutoWire
attr_reader :app, :container, :autowire
class << self
attr_reader :instance
def configure
container = Container.new
yield(container)
@instance = new(Rails.application, container)
freeze
end
def inject(*keys)
@instance.autowire[*keys]
end
end
def initialize(app, container)
@app = app
@container = container
@autowire = Dry::AutoInject(container)
end
end
end
end
The actual service building and registration is going to be done in Rails' initializers, we want to do it only once when the framework is being booted in config/initializers/container.rb:
# frozen_string_literal: true
require_relative '../../src/infra/dependency/container'
require_relative '../../src/infra/dependency/auto_wire'
Infra::Dependency::AutoWire.configure do |container|
container.register :persistence do
ROM.env
end
container.register :your_service do
new YourService(container[:other_service])
end
end
To inject and use the registered service into a let's say controller you would do something like this:
class ApplicationController < ActionController::Base
include Dependency::Container.inject :your_service
def hello_world
puts your_service.get_message
end
end
Next steps
You have all the necessary building blocks, now it's up to you to do your magic! If it was hard for you to follow all of these examples you can also check out my repo and play around with my setup.
I also recommend you watch out for Hanami framework, it's more suited for the DDD approach from the get-go, but do note that the version which supports RBS type system is currently in the release candidate phase. If you come from the PHP world a good analogy for Rails vs Hanami would be Laravel vs Symfony in the terms of their philosophies.