[CleanCode] Replace Conditional with Null Object

Tiếp theo trong loạt bài về Clean Code trong Rails, lại nói về vấn đề Conditional Statement, mình đã có 1 bài viết tại đây về nó: Replace Conditional with Polymorphism. Trong bài này, mình xin được viết về một phương pháp khác để tránh những đoạn Conditional Statement dài dòng trong ứng dụng Rails, đó là sử dụng Null Object. Chắc hẳn, ai đã từng sử dụng Ruby đều cảm thấy quen thuộc với nil, và Ruby đã sinh ra rất nhiều method để Developer có thể làm việc với nil như: nil?, present?, try,...Tuy nhiên việc sử dụng quá nhiều có thể dẫn đến việc trùng lặp code, nếu gặp phải vấn đề như vậy, hãy thử thay thế chúng bằng Null Object.

Example

Chúng ta có model Question:

# app/models/question.rb
def most_recent_answer_text
    answers.most_recent_try(:text) || Answer::MISSING_TEXT
end

Method most_recent_answer_text sẽ tìm đến associations answers cả question, giá trị trả về là text của answer mới nhất, tuy nhiên, ở đây nó phải kiểm tra là giá trị đó có thực sự tồn tại hay không? vì most_recent có thể trả về nil.

# app/model/answer.rb
def self.most_recent
    order(:created_at).last
end

Có một điều phải chú ý là: tất cả các method dùng most_recent sẽ phải check nil như:

# app/models/user.rb
def answer_text_for question
    question.answers.for_user(self).try(:text) || Answer::MISSING_TEXT
end
# app/models/anwser.rb
def self.for_user user
    joins(:completion).where(completions: {user_id: user.id}).last
end

User#answer_text_for như chúng ta thấy phải thực hiện check nil lại 1 lần nữa, điều này là code bị lặp. Chúng ta sẽ không cần phải duplicate lại code trong model UserQuestion bằng Null Object:

# app/models/question.rb
def most_recent_answer_text
    answers.most_recent.text
end
# app/models/user.rb
def answer_text_for question
    question.answers.for_user(self).text
end

Như vậy, chúng ta sẽ assume rằng model Answer sẽ luôn trả về 1 giá trị khác nilUserQuestion sẽ không cần phải check nữa.

# app/models/answer.rb
class Answer < ActiveRecord::Base
    belongs_to :completion
    belongs_to :question: 
    
    validates :text, presence: true
    
    def self.for_user user
        joins(:completion).where(completions: {user_id: user.id}).last || NullAnswer.new
    end
    
    def self.most_recent
        order(:created_at).last || NullAnswer.new
    end
end

most_recentfor_user bây giờ sẽ không trả về giá trị nil nữa, chúng ta sẽ chỉ cần implement class NullAnswer đơn giản như sau:

class NullAnswer
  def text
    "No response"
  end
end

Tuy nhiên, chúng ta vẫn có thể refactor lại model Answer một chút để tránh lặp code.

# app/models/anwser.rb
class Answer < ActiveRecord::Base
    ...
    def self.for_user user
        joins(:completion).where(completions: {user_id: user.id}).last_or_null
    end
    
    def self.most_recent
        order(:created_at).last_or_null
    end
    
    private
     def self.last_or_null
         last || NullAnswer.new
     end
end 

Như vậy, chỉ bằng class NullAnswer đơn giản chúng ta đã tránh được các đoạn check nil bị duplicated trong ứng dụng.

Conclusion

Những lợi ích mà Null Object mang lại:

  • Remove Shotgun Surgery, khi mà một method method trả về nil, thì các method khác phải thực hiện check nil, khi thay đổi method này thì kéo theo sự thay đổi của nhiều method khác.
  • Remove Duplicated Code khi phải thực hiện check nil nhiều lần
  • Code dễ đọc hơn
  • Replace các đoạn conditional statement phức tạp bằng simple command, tuân theo nguyên tắc Tell, Don't ask

All Rights Reserved