Rails refactoring
I. Đặt vấn đề: Website bạn phát triển ngày càng lớn, cùng với đó là số người trong team cũng dần tăng lên. Bạn nhận thấy app design theo style "Fat models, skinny controllers" ngày càng khó khăn và nhiều bug. Hôm nay chúng ta sẽ thảo luận xem sử dụng PORO (Plain Old Ruby Object) như thế nào ...
I. Đặt vấn đề:
Website bạn phát triển ngày càng lớn, cùng với đó là số người trong team cũng dần tăng lên.
Bạn nhận thấy app design theo style "Fat models, skinny controllers" ngày càng khó khăn và nhiều bug.
Hôm nay chúng ta sẽ thảo luận xem sử dụng PORO (Plain Old Ruby Object) như thế nào để làm những dòng code được "sạch", các chức năng phân tách độc lập, rõ ràng và SOLID hơn.
App của bạn có cần refactor không ?
Hãy bắt đầu bằng việc trả lời câu hỏi trên để quyết định xem có cần refactor hay không.
Dưới đây là danh sách các tiêu chí và câu hỏi mà tôi thường tự đặt ra để xác định liệu code có cần refactor hay không:
- Unit test chạy chậm. Với một thiết kế tốt, các chức năng độc lập với nhau thì unit test chạy rất nhanh. Unit test chạy chậm là một dấu hiệu của bad design.
- Fat models hoặc fat controllers. Một Model hoặc Controller có hơn 200 dòng code (LOC), là một ứng cử viên tiềm năng cho việc refactor.
- Code base quá lớn. Nếu bạn có các file ERB/HTML/HAML với hơn 30.000 dòng code, hoặc Ruby source code (không tính gem) nhiều hơn 50.000 LOC thì bạn nên refactor.
Có một cách đơn giản để thống kê số LOC của Ruby source đang là bao nhiêu bằng cách chạy lệnh trong terminal
find app -iname "*.rb" -type f -exec cat {} ;| wc -l
Dòng lệnh này sẽ tìm tất cả các file đuôi .rb trong folder /app, và in ra số dòng code của mỗi file. Nhưng chú ý là, số dòng code thống kê ra ở đây nó bao gồm cả các comment nữa.
Nếu bạn muốn chính xác hơn, thì hãy sử dụng rake task stats, nó sẽ show ra số lượng LOC, số class, số method, tỷ lệ method trong class, và tỷ lệ LOC trên mỗi method.
bundle exec rake stats
Name | Lines | LOC | Class | Methods | M/C | LOC/M |
---|---|---|---|---|---|---|
Controllers | 195 | 153 | 6 | 18 | 3 | 6 |
Helpers | 14 | 13 | 0 | 2 | 0 | 4 |
Models | 120 | 84 | 5 | 12 | 2 | 5 |
Mailers | 0 | 0 | 0 | 0 | 0 | 0 |
Javascripts | 45 | 12 | 0 | 3 | 0 | 2 |
Libraries | 0 | 0 | 0 | 0 | 0 | 0 |
Controller specs | 106 | 75 | 0 | 0 | 0 | 0 |
Helper specs | 15 | 4 | 0 | 0 | 0 | 0 |
Model specs | 238 | 182 | 0 | 0 | 0 | 0 |
Request specs | 699 | 489 | 0 | 14 | 0 | 32 |
Routing specs | 35 | 26 | 0 | 0 | 0 | 0 |
View specs | 5 | 4 | 0 | 0 | 0 | 0 |
Total | 1472 | 1042 | 11 | 49 | 4 | 19 |
Code LOC: 262 Test LOC: 780 Code to Test Ratio: 1:3.0
II. Demo app
1. Cấu trúc application cần refactor
Để bắt đầu, tôi sẽ tạo ra một Rails app với cách viết thông thường.
Giả sử ta có một app mobile để theo dõi thời gian chạy bộ. Nó giao tiếp với Server thông qua các API. Server sẽ có nhiệm vụ, nhận request và hiển thị các thông tin đó trên website.
Mỗi lần push thông tin lên Server, app sẽ đẩy lên ngày, quãng đường, thời gian, và tốc độ trung bình (gọi class lưu object này là Entry).
Phía bên web, ta có trang hiển thị báo cáo về tốc độ trung bình và quãng đường thống kê theo tuần. Nếu tốc độ trung bình trong ngày mà lớn hơn tốc độ trung bình của các users khác, thì ta bắn SMS thông báo cho users.
Ngoài việc đẩy thông tin từ app, ta cũng có thể chủ động tạo dữ liệu trên bằng tay ở trên web bằng cách nhập form:
Code sample bạn có thể lấy ở đây
2. Code base
Cấu trúc hiện tại của folder app sẽ như sau:
├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── helpers │ ├── application_helper.rb │ ├── entries_helper.rb │ └── statistics_helper.rb ├── mailers ├── models │ ├── entry.rb │ └── user.rb └── views ├── devise │ └── ... ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb
Tôi sẽ không nói về model User, vì nó sử dụng gem Devise và không có gì đặc biệt cả.
Model Entry sẽ là nơi chứa các business logic cho app và có quan hệ belongs_to User.
Ta có xử lý validate cho distance, time_period, date_time, status với mỗi record Entry.
Mỗi lần tạo 1 entry, ta sẽ so sánh tốc độ trung bình của nó với tất cả các user khác trong hệ thống, và thông báo tới User qua SMS.
- Gist sample
Code trong entries_controller.rb chỉ có CRUD cơ bản.
- Gist sample
Trong khi đó, statistics_controller.rb chứa các hàm tính toán weekly report, StatisticsController#index lấy ra entries của user login và nhóm nó lại theo tuần. Sau đó nó dùng private methods để decorate kết quả trả về.
- Gist sample
Dưới đây là view show ra list các entries cho login user (index.html.erb)
- Gist sample
Ta đã có sử dụng partial _entry.html.erb để làm code DRY, và có thể tái sử dụng.
- Gist sample
Tương tự với _form cũng là partial. Ta sẽ dùng chung code cho cả action new và edit, nên tạo partial form:
- Gist sample
Với trang weekly report statistics/index.html.erb, show ra thống kê theo tuần của entries:
- Gist sample
Và cuối cùng, ta có file entries_helper.rb includes 2 cái helper là readable_time_period và readable_speed để làm các attributes dễ đọc hơn.
- Gist sample
Hệ thống không có gì đặc biệt cả. Hầu hết các bạn sẽ tranh luận rằng, với cái hệ thống như thế này, việc tái cấu trúc sẽ trái lại với nguyên tắc KISS, làm nó phức tạp hơn.
Vậy nó có đang để refactor?
Chắc chắn là không. Nếu bạn check lại các tiêu chí tôi đã nêu ở trên, thì hệ thống này không phải là ứng cử viên để refactor. Nhưng ta vẫn sẽ refactor nó để phục vụ cho mục đích của bài viết này.
3. Vòng đời của một request
Ta hãy nói về kiến trúc của Rails MVC.
Thông thường, khi bắt đầu, browser sẽ tạo một request kiểu như: https://www.toptal.com/jogging/show/1
Web server tiếp nhận request và dùng routes để tìm tới Controller mà nó xử lý.
Controller phân tích request, cookies, sessions, ... và gọi tới Model để lấy dữ liệu.
Model là class giao tiếp với Database, validate, lưu trữ dữ liệu tùy thuộc vào business logic.
Phần Views là những cái gì mà user nhìn thấy: thông qua HTML, CSS, XML, JS, JSON, ...
Vậy vòng đời (life cycle) của 1 request trong Rails sẽ như sau:
Cái tôi muốn làm là add thêm các abstraction sử dụng POROs (Plain Old Ruby Objects) và xây dựng mô hình như bên dưới (action create/update):
Cho action index/show nữa:
Bằng cách add thêm các POROs abtractions, chúng ta sẽ phân tách được các phần rạch ròi với nhau, mỗi phần mang 1 nhiệm vụ cố định (chính là tính Single Responsibility Principle aka SRP trong SOLID), cái mà Rails còn thiếu.
III. Refactoring
1. Tái cấu trúc, nhiệm vụ của từng phần
Sau đây tôi sẽ tái cấu trúc lại từng phần, nhưng lưu ý rằng, đây không phải là những quy tắc mà bạn bắt buộc phải tuân theo. Nó chỉ là giải pháp option, giúp code của bạn rõ ràng và dễ nâng cấp, bảo trì hơn thôi.
- ActiveRecord models chỉ chứa các association và constant. Điều đó có nghĩa không có khai báo callback, validation bên trong model.
- Skinny Controllers bằng cách gọi các Service object. Một số bạn sẽ hỏi vậy Controllers sẽ làm gì trong khi logic nhét hết vào Service rồi? Controllers sẽ là nơi để xử lý routing HTTP, parse parameters, authentication, gọi Service, bắt exception, response lại theo format, và trả về HTTP status code.
- Service gọi Query object. Sử dụng instance methods chứ không phải class methods. Và có rất ít public methods bên trong để phù hợp với SRP (Single Responsibility Principle).
- Các câu queries thực hiện bên trong các Query Object. Query object methods sẽ trả về một object, hash hoặc array.
- Tránh sử dụng Helpers, thay nó bằng Decorator.
- Tránh dùng concerns, dùng Decorators/Delegators thay thế.
- Sử dụng Value Object từ models để giữ code được sạch và group lại những attributes liên quan đến nhau.
- Luôn chỉ truyền 1 biến instance ra view.
2. Refactoring
Trước khi tiến hành, tôi muốn nói thêm 1 điều nữa. Khi bạn refactor code, bạn phải thường xuyên tự hỏi: "Liệu nó có phảỉ là phương pháp refactor tối ưu?"
Nếu bạn cảm thấy nó SRP và ISP hơn (ngay cả khi phải thêm nhiều file và code) thì là ổn. Xét cho cùng, việc phân tách tốt sẽ hỗ trợ viết Unit Test đơn giản và nhanh hơn rất nhiều.
2.1. Sử dụng Value objects
Value object là cái gì?
Theo như Martin Fowler giải thích (mình cũng chả biết dịch thế nào cho sát nghĩa):
Value Object is a small object, such as a money or date range object. Their key property is that they follow value semantics rather than reference semantics.
Đại loại là nó sẽ nhóm các attributes lại thành 1 object. Khi ta sử dụng object đấy, đồng nghĩa với việc sẽ sử dụng tất cả các attributes bên trong - phù hợp cho 1 nhiệm vụ nhất định.
Một trong những ưu điểm của Value object là tính "biểu cảm" của nó trong dòng code. Code của bạn sẽ clear hơn khi gọi nhóm lại các attributes cần thiết lại thành 1 tên duy nhất.
Một lợi ích lớn khác của nó là tính bất biến (immitability). Một object "bất biến" rất quan trọng. Khi ta cần lưu trữ một list các dữ liệu, thì nên sử dụng Value object.
Value objects phải tuân theo những nguyên tắc sau:
- Chứa nhiều attributes.
- Attributes phải bất biến trong suốt vòng đời của object.
- Các attributes của object phải bình đẳng.
Trong ví dụ ở trên, ta sẽ tạo EntryStatus value object để đại diện cho attributes Entry#status_weather và Entry#status_landform
class EntryStatus include Comparable class NotValidEntryStatus < StandardError; end OPTIONS = { weather: %w(Sunny Rainy Windy Dry), landform: %w(Beach Cliff Desert Flat) } attr_reader :weather, :landform def initialize(weather, landform) @weather, @landform = weather, landform end def <=>(other) weather == other.weather && landform == other.landform end end
Trong Entry model sử dụng object mà ta vừa tạo:
class Entry < ActiveRecord::Base validates :status_weather, inclusion: { in: EntryStatus::OPTIONS[:weather] } validates :status_landform, inclusion: { in: EntryStatus::OPTIONS[:landform] } .... def status @status ||= EntryStatus.new(status_weather, status_landform) end def status=(status) self[:status_weather] = status.weather self[:status_landform] = status.landform @status = status end .... end
Trong EntryController#create cũng cần đổi lại:
def create @entry = Entry.new(entry_params) @entry.user_id = current_user.id @entry.status = EntryStatus.new( entry_params[:status_weather], entry_params[:status_landform] ) if @entry.save flash[:notice] = "Entry was successfully created." else flash[:error] = @entry.errors.full_messages.to_sentence end redirect_to root_path end
2.2. Tách Service Object
Vậy Service object là gì?
Công việc của một service object là chứa những dòng code phục vụ cho một business logic cụ thể. Không giống như "fat model" - chứa nhiều methods cho tất cả những xử lý logic cần thiết, mỗi Service Objects chỉ phục vụ cho một mục đích duy nhất mà thôi.
Vậy ích lợi của nó là gì?
- Cô lập. Service object giúp các đối tượng độc lập hơn.
- Dễ đọc. Service object thể hiện application làm công việc gì. Ta chỉ cần đọc lướt qua Services folder là có thể nắm được những chức năng mà app cung cấp.
- Dọn dẹp Controller và Model. Controllers biến đổi request thành các arguments (params, sessions, cookies), truyền chúng xuống Service và redirect hoặc render từ cái service trả về. Trong khi đó, models chỉ làm việc với DB và association. Việc tách code ra từ controllers/models sang service object sẽ support cho tính SRP, viết unit test cũng dễ hơn nữa. :v
- DRY. Tôi giữ Service object giản đơn nhất có thể. Khi viết một Service mới, ta sẽ so sánh nó với những service khác xem có thể tái sử dụng được phần nào không.
- Tăng tốc unit test. Với đầu vào và đầu ra rõ ràng, đồng thời dễ mock/stub các object liên quan, service objects giúp viết unit test dễ và chạy nhanh hơn phải không nào?
- Gọi từ bất cứ đâu. Service object thường được gọi ra từ controllers hoặc từ các services khác, cron job, background job, rake task, console, ...
Nhưng nói đi cũng phải nói lại, không có gì là hoàn hảo cả. Với action cực cực kỳ đơn giản, thì việc dùng Service object lại khiến code trông lằng nhằng hơn là không viết.
Vậy khi nào bạn cần tách service object?
Thông thường, Service objects thích hợp cho những hệ thống vừa và lớn - khi mà lượng code logic vượt quá nhiệm vụ cơ bản của CRUD. Dưới đây là một số tiêu chí để đánh giá có nên tách thành service object hay không:
- Action phức tạp.
- Action tương tác với nhiều models.
- Action tương tác với service khác bên ngoài.
- Action không phải là core của model.
- Action có thể giải quyết cho nhiều chức năng khác nhau.
Ta nên thiết kế Service object dư lào?
Thiết kế của class Service khá đơn giản, chả cần đến gem gủng hay config gì cả. Tôi thường follow theo các tiêu chí sau khi design service:
- Không được lưu trữ trạng thái của object.
- Sử dụng instance method, không dùng class method.
- Chỉ có rất ít public method.
- Methods trong Service nên trả về list các objects, không trả về boolean.
- Services được đặt trong folder app/services. Nên dùng thêm các subfolder cho những service có nghiệp vụ liên quan đến nhau.
- Tên service bắt đầu bằng động từ (không cần có hậu tố là service). Ví dụ như: ApproveTransaction, SendTestNewsletter, ImportUsersFromCsv.
Nếu bạn nhìn lại vào Controller StatisticsController#index, bạn sẽ nhận ra nhóm các methods có thể tách được (weeks_to_date_from, weeks_to_date_to, avg_distance, ...). Trong trường hợp này, ta hãy tạo ra class Report::GenerateWeekly để trích xuất phần logic liên quan đến report trong Controller ra.
module Report class GenerateWeekly WeeklyReport = Struct.new( :week_number, :date_from, :date_to, :count_entries, :avg_distance, :avg_speed ) def initialize(user) @user = user end def call @user.entries.group_by(&:week).map do |week, entries| WeeklyReport.new( week, weeks_to_date_from(week), weeks_to_date_to(week), entries.count, avg_distance(entries), avg_speed(entries) ) end end private def weeks_to_date_from(week) (Date.new + week.to_i.weeks).to_s.split(',')[0] end def weeks_to_date_to(week) (Date.new + week.to_i.weeks + 7.days).to_s.split(',')[0] end def avg_distance(entries) distances = entries.inject(0){|sum, n| sum + n.distance } (distances / entries.count).round(2) end def avg_speed(entries) speeds = entries.inject(0){|sum, n| sum + n.speed } (speeds / entries.count).round(2) end end end
Giờ cái StaticsController đã trở nên clear hơn
class StatisticsController < ApplicationController def index @weekly_reports = Report::GenerateWeekly.new(current_user).call end end
Túm cái váy lại, bằng cách sử dụng Service object, chúng ta đã gói những đoạn code logic phục vụ cho một công việc cụ thể vào class nhất định. Mặc dù số dòng code nhiều, nhưng lại dễ đọc và test hơn nhiều.
2.3. Tách Query object
Query object là gì?
Query object là một PORO, đại diện cho câu query database. Nó có thể sử dụng ở nhiều nơi trong application. Bạn nên đưa những xử lý SQL query phức tạp vào những class riêng của nó.
Mỗi Query object chịu trách nhiệm trả về kết quả dựa trên business rules.
Ở trong code ví dụ, ta không có câu query phức tạp, nên việc sử dụng query object là thừa thãi. Tuy nhiên, ta cứ thử mổ xẻ xem thế nào (yaoming).
Bắt đầu với query bên trong class Report::GenerateWeekly#call, tạo ra file generate_entries_query.rb
#Report::GenerateWeekly#call def call @user.entries.group_by(&:week).map do |week, entries| WeeklyReport.new( ... ) end end
thay thế bằng
def call weekly_grouped_entries = GroupEntriesQuery.new(@user).call weekly_grouped_entries.map do |week, entries| WeeklyReport.new( ... ) end end
Query object không cần phải kế thừa từ ActiveRecord::Base, và chỉ có một nhiệm vụ duy nhất - execute query.
2.4. Đưa validation vào Form object
Ở mục 1, ta đã nói đến việc models chỉ có nhiệm vụ chứa các associations và constant. Vậy hãy bắt đầu remove đi các validation và sử dụng Form object thay thế nó.
Tại sao cần sử dụng Form Objects?
Khi ta cần refactor app, hãy luôn luôn nhớ trong đầu quy tắc Single Responsibility Principle (SRP). Model chỉ có nhiệm vụ giao tiếp với DB, vì thế nó đếch cần quan tâm xem user sẽ làm gì với dữ liệu của nó.
Đó là lý do Form object ra đời.
Form object chịu trách nhiệm đại diện cho form trong app (form ở đây ta có thể hiểu là những data input). Nó sẽ đóng vai trò như một bộ lọc dữ liệu, trước khi chuyển tới nơi xử lý.
Khi nào bạn nên sử dụng Form object?
- Khi bạn muốn tách validation ra khỏi Rails models.
- Khi có nhiều model có thể update bởi một form.
Bạn tạo form object dư lào?
- Tạo Ruby class.
- Include ActiveModel::Model
- Khác với trong model, bạn không được lưu dữ liệu bên trong object này.
Ví dụ ta tạo file entry_form.rb
# app/forms/entry_form.rb class EntryForm include ActiveModel::Model attr_accessor :distance, :time_period, :date_time, :status_weather, :status_landform validates_presence_of :distance, :time_period, :date_time validates_numericality_of :distance, :time_period validates :status_weather, inclusion: { in: EntryStatus::OPTIONS[:weather] } validates :status_landform, inclusion: { in: EntryStatus::OPTIONS[:landform] } end
Và trong Service CreateEntry ta bắt đầu sử dụng Form Object
class CreateEntry ...... ...... def call @entry_form = ::EntryForm.new(@params) if @entry_form.valid? .... else .... end end end
2.5. Đưa callbacks vào trong Service Object
Ta đã hoàn thành việc đưa validations vào trong Form object. Nhưng model vẫn còn các callback (after_create trong Entry model compare_speed_and_notify_user)
Tại sao ta cần bỏ callback trong models?
Rails developers thường bắt dầu nhận ra cái dở của callback khi test. Nếu bạn không test model, bạn sẽ vỡ mặt khi app dần to ra và nhiều logic cần gọi hoặc bỏ qua các callback đấy.
Khi một object được save, mục đích của object được hoàn thành. Nên nếu ta vẫn thấy callback hoạt động sau khi object được save, nó giống như kiểu vượt quá trách nhiệm của object và khi đó sẽ phát sinh ra nhiều vấn đề.
Một cách đơn giản để giải quyết là chuyển các callback vào trong service object liên quan. Ở ví dụ của chúng ta, việc send SMS cho user liên quan tới CreateEntry Service Object và nó không phụ thuộc vào Entry model.
Với cách làm như vậy, ta không còn phải stub method compare_speed_and_notify_user khi viết test nữa. Giờ thì code trong CreateEntry sẽ là:
class CreateEntry class NotValidEntryRecord < StandardError; end def initialize(user, params) @user = user @params = params end def call @entry_form = ::EntryForm.new(@params) if @entry_form.valid? entry = Entry.new(@params) entry.user = @user entry.status = EntryStatus.new( @params[:status_weather], @params[:status_landform] ) compare_speed_and_notify_user entry.save! else raise(NotValidEntryRecord, @entry_form.errors.full_messages.to_sentence) end end private def compare_speed_and_notify_user entries_avg_speed = (Entry.all.map(&:speed).sum / Entry.count).round(2) if speed > entries_avg_speed msg = 'You are doing great. Keep it up superman. :)' else msg = 'Most of the users are faster than you. Try harder dude. :(' end NexmoClient.send_message( from: 'Toptal', to: user.mobile, text: msg ) end end
2.6. Sử dụng Decorators thay cho Helpers
Ta co gem Draper hỗ trợ tạo decorators rất tốt, nhưng trong ví dụ này, tôi sẽ thử dùng thư viện sẵn có của Rails SimpleDelegator
# app/decorators/base_decorator.rb require 'delegate' class BaseDecorator < SimpleDelegator def initialize(base, view_context) super(base) @object = base @view_context = view_context end private def self.decorates(name) define_method(name) do @object end end def _h @view_context end end
Tại sao là _h method?
Method này giống như proxy cho view context vậy. Mặc định, view context là một instance của view class, mà view class là ActionView::Base. Bạn có thể chọc tới view helpers như sau:
_h.content_tag :div, 'my-div', class: 'my-class'
Để tiện lợi hơn, ta add decorate method vào trong ApplicationHelper:
module ApplicationHelper # ..... def decorate(object, klass = nil) klass ||= "#{object.class}Decorator".constantize decorator = klass.new(object, self) yield decorator if block_given? decorator end # ..... end
Giờ ta biến EntriesHelper thành decorators:
# app/decorators/entry_decorator.rb class EntryDecorator < BaseDecorator decorates :entry def readable_time_period mins = entry.time_period return Time.at(60 * mins).utc.strftime('%M <small>Mins</small>').html_safe if mins < 60 Time.at(60 * mins).utc.strftime('%H <small>Hour</small> %M <small>Mins</small>').html_safe end def readable_speed "#{sprintf('%0.2f', entry.speed)} <small>Km/H</small>".html_safe end end
Và ta có thể sử dụng readable_time_period và readable_speed như sau:
# app/views/entries/_entry.html.erb <td><%= decorate(entry).readable_speed %> </td> <td><%= decorate(entry).readable_time_period %></td>
4. Cấu trúc sau khi refactor
app ├── assets │ └── ... ├── controllers │ ├── application_controller.rb │ ├── entries_controller.rb │ └── statistics_controller.rb ├── decorators │ ├── base_decorator.rb │ └── entry_decorator.rb ├── forms │ └── entry_form.rb ├── helpers │ └── application_helper.rb ├── mailers ├── models │ ├── entry.rb │ ├── entry_status.rb │ └── user.rb ├── queries │ └── group_entries_query.rb ├── services │ ├── create_entry.rb │ └── report │ └── generate_weekly.rb └── views ├── devise │ └── .. ├── entries │ ├── _entry.html.erb │ ├── _form.html.erb │ └── index.html.erb ├── layouts │ └── application.html.erb └── statistics └── index.html.erb
Kết luận
Mặc dù bài viết này tập trung vào Rails, nhưng tư tưởng có nó có thể áp dụng cho bất kì ngôn ngữ, Framework nào.
Với việc dùng MVC đơn thuần, nhiều thứ bị dính vào nhau, nó sẽ khiến quá trình phát triển app dần chậm lại và phát sinh nhiều bug. Bằng cách tạo ra các class POROs đơn giản, ta đã thành công trong việc "dọn dẹp" code, giúp nó trở nên dễ đọc, dễ hiểu, dễ phát triển và test rất nhanh.
Nguồn:
- https://www.toptal.com/ruby-on-rails/decoupling-rails-components