Refactor rails

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ìnhquã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.

Code trong entries_controller.rb chỉ có CRUD cơ bản.

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ề.


Dưới đây là view show ra list các entries cho login user (index.html.erb)

Ta đã có sử dụng partial _entry.html.erb để làm code DRY, và có thể tái sử dụng.

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:

Với trang weekly report statistics/index.html.erb, show ra thống kê theo tuần của entries:

Và cuối cùng, ta có file entries_helper.rb includes 2 cái helper là readable_time_periodreadable_speed để làm các attributes dễ đọc hơn.

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_weatherEntry#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_periodreadable_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:

All Rights Reserved