[UA] Предметно-орієнтована архітектура Rails
Published: 2022-02-22 13:41:07

---
title: [UA] Предметно-орієнтована архітектура Rails
published: true
tags: 
canonical_url: https://bpohoriletz.github.io/2018/10/31/domain-driven-rails-architecture-pattern-ua.html
---

- Час: 20-30 хвилин
- Рівень: Середній/Просунутий
- Код: [GitHub](https://github.com/bpohoriletz/bpohoriletz.github.io/tree/master/samples/domain_driven_rails_architecture_pattern)
- Ресурси:    
  - [The Modular Monolith: Rails Architecture – Dan Manges](https://medium.com/@dan_manges/the-modular-monolith-rails-architecture-fb1023826fc4)
  - [Counterintuitive Rails - Ivan Nemytchenko](https://www.youtube.com/watch?v=KtD32fO_owU&index=4&list=PLqwEgoaqsYziFoM8UN2GmWc0BySnWUozK)
  - [Rails Parts – Tom Rothe](http://tomrothe.de/posts/rails_parts.html)
  - [Scaling Teams using Tests for Productivity and Education - Julian Nadeau](https://www.youtube.com/watch?v=InFnu8bYi6s&list=PoLqwEgoaqsYziFoM8UN2GmWc0BySnWUozK)

Дана стаття описує структуру проекту написаного на Rails і в нійвикористані ідеї з вищезазначених статтей. Окрім того приклад містить всобі можливість досить просто використовувати автоматичні додатки дляперевірки якості коду. Головними вимогами до проекту є:

1. Розділення перегляду (репрезентації) та бізнес-логіки (вашого домену)
2. Розділення залежностей (gems) і як результат - можливістьвиконувати юніт тести в ізольованому середовищі
3. Рішення повинно бути простим і зрозумілим (Rails чудовий фреймворкі ми не збираємось з ним боротись)

TLDR - [Github repo](https://github.com/bpohoriletz/bpohoriletz.github.io/tree/master/samples/domain_driven_rails_architecture_pattern) та [commit](https://github.com/bpohoriletz/bpohoriletz.github.io/commit/51220e7f4274e35cf6c071a0cf3ba683dd2af938) з усіма змінамизастосованими до нового проекту на Rails

# **Розділення перегляду та бізнес-логіки**

Першим кроком є чітке розділення перегляду та бізнес-логіки в структуріпроекту (та в вашій голові). Для досягнення даного результату мистворимо нову папку `representations/` і перемістимо в неї все що нампотрібно для того щоб показати суб’єкти домену. В прикладі ними є:

- `representations/`
  - `assets/`
  - `controllers/`
  - `decorators/`
  - `public/`
  - `views/`
  - `vendor/`
  - `routes.rb`

> Я надаю перевагу використанню декораторів замість helper тому тут немає папки `helpers/`.

Далі нам потрібно побудувати структуру тек для суб’єктів та логіки домену

- жодне з цих двох понять не повинно бути присутнім в частині проекту,що відповідає за представлення. Отож давайте створимо нову теку `domain/`і перемістимов неї моделі та налаштування для бази даних:    
  - `domain/`
    - `contexts/`
    - `database.yml`

Назва `contexts/` тут є посиланням на шаблон [Bounded Context](https://martinfowler.com/bliki/BoundedContext.html) в теоріїПредметно-орієнтованого програмування. Ви можете назвати їх по-іншому і мати будь-яку структурутек всередині.

Тепер нам потрібно налаштувати Rails для роботи з новою структурою тек. Дані налаштуваннязнаходяться всередині файлу `config/application.rb` і використовують API [Rails::Application](https://api.rubyonrails.org/classes/Rails/Application/Configuration.html#method-i-paths)

```
 # 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
```

Після цієї зміни Rails буде працювати з новою структурою тек так, ніби оригінальна ніколи не змінювалась -autoloading, eager loading, asset compilation - всі ці процеси будуть повністю функціональні.

> На мою особисту думку представлення `ApplicationController` та `ApplicationRecord` як `Concern`покращує гнучкість коду, тому в даному прикладі вони є `Concerns` і є додатковий файл `config/initializers/draper.rb`для того щоб ‘Draper’ зміг з ними працювати

```
 # 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
```

# **Розділення середовищ та побудова незалежних тестів**

Раніше ми розділили представлення і предметну область, тепер окремі тести длякожної частини будуть великим плюсом для проекту. Правильного написані тести будутьшвидші, ізольовані та незалежні. Спершу підготуємо середовище для них:

1. Створимо окремі `Gemfile` та `Gemfile.lock` для представлення та предметної області
2. Налаштовуємо головний `Gemfile` так щоб він використовував нові специфічні для кожноїобласті `Gemfile`
3. Налаштуємо незалежні тестові середовища для представлення та предметної області

Додавання додаткових `Gemfile` не є чимось складним - ми просто створюємо нові файли і переміщуємов них залежності (gem) з головного.

Налаштувати головний `Gemfile` для роботи з розподіленими залежностями є також доволі простим - `bundler`вже має метод для завантаження додаткових файлів, якщо виникнуть проблеми при завантаженні розподіленихзалежностей ви побачите ті самі помилки що і при завантаженні звичайного `Gemfile`

```
 # Gemfile

 54 %w[representations/Gemfile domain/Gemfile].each do |custom_gemfile|
 55 eval_gemfile custom_gemfile
 56 end
```

Налаштування незалежних тестових середовищ є найскладнішою частиною (і найімовірніше саме тут виникнутьдодаткові проблеми при рості проекту). Перший крок - запустити команду `rspec --init` в теках `representations/`та `domain/`. В результаті нові таки `representations/spec` та `domain/spec` будуть додані.

`spec/spec_helper.rb` також буде додано автоматично, проте `spec/rails_helper.rb` автоматично створеноне буде і нам доведеться додати і налаштувати його вручну.

#### Налаштування тестового середовища предметної області

Для початку ми копіюємо файл `spec/rails_helper.rb` в `domain/spec/rails_helper.rb` і видаляємо з нього все до лінії`RSpec.configure do |config|`. Це робиться для того щоб не завантажувати жодних залежностей - ми їх завантажимовручну пізніше. Після цього в нас не буде можливості запустити тести, проте це лише перший крок.

Далі ми завантажуємо всі необхідні залежності:

- завантажуємо `active_record` та `rspec-rails`

```
  # domain/spec/rails_helper.rb

  3 require 'active_record/railtie'
  4 require 'active_support'
  5 require 'rspec/rails'
```

- завантажуємо залежності тестового середовища

```
  7 ENV['RAILS_ENV'] ||= 'test'
  8 require 'spec_helper'
  9 require 'database_cleaner'
 10 require 'factory_bot'
 11 require 'pry-byebug'
```

- створюємо Application для роботи з `rspec-rails` (нажаль найбільшслабка частина)

```
 13 ContextsTestApplication = Class.new( ::Rails::Application )
 14 ::Rails.application = ContextsTestApplication.new
```

- під’єднуємось до бази даних

```
 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
```

- завантажуємо предметну область (спільні concerns в першу чергу оскільки немаємеханізму автозавантаження)

```
 24 %w[concerns contexts].each do |folder|
 25 Dir[File.expand_path( "../#{folder}/**/*.rb", __dir__ )].each { |f| require f }
 26 end
```

- завантаження файлів initializer/support

```
 28 Dir['./spec/support/*.rb'].each { |f| require f }
 29
 30 RSpec.configure do |config|
```

#### налаштування тестового середовища для представлення

Налаштування тестового середовища для представлення є досить схожим -єдина різниця це залежності якими завантажуємо:

- завантажуємо `action_controller` та `rspec-rails`

```
  # representations/spec/rails_helper.rb
  3 require 'action_controller/railtie'
  4 require 'active_support'
  5 require 'rspec/rails'
  6 require 'spec_helper'
```

- створюємо Application для `rspec-rails` та завантажуємо routes

```
  8 RepresentationsTestApplication = Class.new( ::Rails::Application )
  9 ::Rails.application = RepresentationsTestApplication.new
 10 require_relative '../routes'
```

- завантажуємо залежності

```
 12 require 'pry-byebug'
 13 require 'uuid'
```

- завантажуємо код представлення (спільні concerns в першу чергу оскільки немаємеханізму автозавантаження)

```
 15 %w[concerns controllers decorators].each do |folder|
 16 Dir[File.expand_path( "../#{folder}/**/*.rb", __dir__ )].each { |f| require f }
 17 end
```

Тепер у нас є змога запускати різні тести в залежності від контексту і для кожного контексту:

- тести можуть включати лише юніт-тести
- ми змушені залишатися всередині контексту при написанні тесту
- завантаження/перезавантаження середовища є швидким (завантаження файлів тривало 2.65 секунди коли тестизапускалося з головного проекту і лише 0.9 секунд якщо запускалась незалежно)

> Оскільки файли налаштування тестового середовища знаходяться всередині тек `representations/` та `domain/`,ці тeки не можуть бути всередині `app/` - тому що Rails спробує завантажити ці файли в production.

# **Фінальні частини**

Як я вже згадував у [попередній](https://dev.to/bpohoriletz/modular-monolith-example-5dnd) статті, я вважаю що тести,що знаходяться в головній теці `spec/`, `test/` не повинні бути юніт-тестамиі завжди тестувати декілька компонентів проекту. Протилежне твердження єістинним для тестів що знаходяться в теках `representations/spec/` та `domain/spec`завжди повинні бути юніт-тестами.

Одна проблема з даним налаштуванням є те що для того щоб запускати тестивсередині ізольованого середовища ви повинні мати окремі `Gemfile.lock` і це можеспричинити різницю у версіях gem які використовується для тестів що запускаються візоляції і тестів що допускаються як частина глобальної тестової системи. Давайтенапишемо тест який би нам повідомляв якщо така ситуація станеться:

```
  # 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
```

[Приклад](https://github.com/bpohoriletz/bpohoriletz.github.io/tree/master/samples/domain_driven_rails_architecture_pattern) проекту також включає Git hooks які будуть встановленіна ваш проект якщо ви запустите `./bin/setup` і будуть автоматично виконані передта після того як ви зробити commit. pre-commit hook запускає rubocop для перевіркивсіх змін які будуть включені в commit, post-commit hook надає вам можливість запускатиrails\_best\_practices, reek, brakeman і mutant для вашого коду.

# **Підсумок**

Мені дуже подобається гнучкість даної архітектури - при потребі можна заізолювати будь-якучастину коду і ставитись до неї як до незалежного unit. В той же час вона, здебільшого,використовує Rails API - тож ми не боремося з Rails, скоріше це ще один спосіб для організаціїкоду. Мені кортить випробувати дану архітектуру з більш складними та legacy проектами - їїзастосування повинно бути доволі простим в обох випадках.

Посилання:

- [Проект](https://github.com/bpohoriletz/bpohoriletz.github.io/tree/master/samples/domain_driven_rails_architecture_pattern)
- [Commit](https://github.com/bpohoriletz/bpohoriletz.github.io/commit/51220e7f4274e35cf6c071a0cf3ba683dd2af938)