adventures in making stuff with Daniel Higginbotham

A Detailed Look at a Small DCI Refactoring in Ruby

30 November 2012

UPDATE: this isn't actually DCI! Whoops!

In this post I go over a small refactoring to clean up some code in Whoops by implementing the DCI pattern. I'll cover the actual code changes and include my usual hand-wringing about what could be done better.

This refactoring was inspired by Jim Gay's book Clean Ruby. Jim Does an excellent job of concisely explaining what the DCI pattern (data, context, interaction) is, why it's useful, and how you can implement it in Ruby. This post isn't a review of Clean Ruby, but I will say that it's worth the money - it's actually rekindled my enthusiasm for Ruby!

Background

Whoops is a free, open-source Rails engine for logging. It's similar to Airbrake, Errbit, and Exceptional except that it's not limited to exceptions - you can log any event.

The Original Code and Its Defects

The code that got refactored is concerned with processing new events. You can see the entry point to this use case at lines 18-28 here:

class Whoops::Event
  include Mongoid::Document
  include FieldNames

  belongs_to :event_group, :class_name => "Whoops::EventGroup", :index=>true

  field :details
  field :keywords, :type => String
  field :message, :type => String
  field :event_time, :type => DateTime

  index([[:event_group_id,Mongo::ASCENDING],[:event_time, Mongo::DESCENDING]])

  validates_presence_of :message  

  before_save :set_keywords, :sanitize_details

  def self.record(params)
    params = params.with_indifferent_access

    event_group_params                    = params.slice(*Whoops::EventGroup.field_names)
    event_group_params[:last_recorded_at] = params[:event_time]
    event_group_params
    event_group = Whoops::EventGroup.handle_new_event(event_group_params)

    event_params = params.slice(*Whoops::Event.field_names)
    event_group.events.create(event_params)
  end 

  def self.search(query)
    conditions = Whoops::MongoidSearchParser.new(query).conditions
    where(conditions)
  end

  def set_keywords
    keywords_array = []
    keywords_array << self.message
    add_details_to_keywords(keywords_array)
    self.keywords = keywords_array.join(" ")
  end

  def sanitize_details
    if details.is_a? Hash
      sanitized_details = {}
      details.each do |key, value|
        if key =~ /\./
          key = key.gsub(/\./, "_")
        end

        if value.is_a? Hash
          child_keys = all_keys([value])
          if child_keys.any?{ |child_key| child_key =~ /\./ } 
            value = value.to_s
          end
        end

        sanitized_details[key] = value
      end

      self.details = sanitized_details
    end
  end

  def all_keys(values)
    keys = []
    values.each do |value|
      if value.is_a? Hash
        keys |= value.keys
        keys |= all_keys(value.values)
      end
    end
    keys
  end

  private


  def add_details_to_keywords(keywords_array)
    flattened = details.to_a.flatten
    flattened -= details.keys if details.respond_to?(:keys)

    until flattened.empty?
      non_hash = flattened.select{ |i| !i.is_a?(Hash) }
      keywords_array.replace(keywords_array | non_hash)
      flattened -= non_hash

      flattened.collect! do |i|
        i.to_a.flatten - i.keys
      end.flatten!
    end
  end
end

The above record method calls handle_new_event in Whoops::EventGroup (lines 21-41):

class Whoops::EventGroup
  # notifier responsible for creating identifier from notice details
  include Mongoid::Document
  include FieldNames

  [
    :service,
    :environment,
    :event_type,
    :message,
    :event_group_identifier,
    :logging_strategy_name
  ].each do |string_field|
    field string_field, :type => String
  end

  field :last_recorded_at, :type => DateTime
  field :archived, :type => Boolean, :default => false
  field :event_count, :type => Integer, :default => 0

  class << self
    def handle_new_event(params)
      identifying_params = params.slice(*Whoops::EventGroup.identifying_fields)
      event_group = Whoops::EventGroup.first(:conditions => identifying_params)

      if event_group
        event_group.attributes = params
      else
        event_group = Whoops::EventGroup.new(params)
      end

      if event_group.valid?
        event_group.send_notifications
        event_group.archived = false
        event_group.event_count += 1
        event_group.save
      end

      event_group
    end
  end

  has_many :events, :class_name => "Whoops::Event"

  validates_presence_of :event_group_identifier, :event_type, :service, :message

  def self.identifying_fields
    field_names - ["message", "last_recorded_at"]
  end

  # @return sorted set of all applicable namespaces
  def self.services
    all.distinct(:service).sort
  end

  def should_send_notifications?
    (archived || new_record) && Rails.application.config.whoops_sender
  end

  def send_notifications
    return unless should_send_notifications?
    matcher = Whoops::NotificationSubscription::Matcher.new(self)
    Whoops::NotificationMailer.event_notification(self, matcher.matching_emails).deliver unless matcher.matching_emails.empty?
  end
end

I've included the full source of the files to show why this code doesn't belong. See, one of the main ideas in Jim's book is that code becomes unmaintainable when we start overburdening classes with methods that are only needed in specific contexts, or use cases. Additionally, we scatter all the code needed in one context across multiple classes and files rather than keeping it all in one place, creating a cognitive burden.

Violating The Single Responsibility Principle

In this case, the context is "record an event." The code we've added to the Whoops::Event class begins to overburden the class by giving it a new responsibility and requiring that it "know" more about the outside world. Here's what the class is already responsible for, along with what we're adding:

  • CRUD events
  • Massage event data before persistence
  • NEW Prepare event group data (lines 21-23)
  • NEW Initiate event group handling of new event (line 24)
  • NEW Figure out how to separate event-relevant data from event params (line 27)

In this case, the impact isn't that high because we still don't even have 100 LOC for the entire class. But by continuing to shove code into a class just because a class's data or existing behavior is somehow involved, we transform our classes from usable, focused abstractions into code warehouses. And I really do mean warehouse. You have to start using signage in the form of comments or mixins to find your way around. "Aaah yes, here we are, the email notification section!" or "Oh shit! it looks like someone put the methods for handling user registration at different ends of the file. Let's put those two guys together. There, what a tidy warehouse!"

The Whoops::EventGroup class was becoming a code warehouse. Here are the new responsibilities brought on by handle_new_event

  • CRUD event groups
  • validate event groups
  • NEW send a notification on new events
  • NEW figure out whether a notification should be sent (lines 56-58)

This notification code is most definitely not the responsibility of a Whoops::EventGroup. But here it is, because hey, fat model skinny controller.

Spreading Out Related Code

The above two files also show how related code gets spread out. In this case, to understand the full behavior of "handle new event" you have to start in the Whoops::Event file, then go to the Whoops::EventGroup file, then return back to the Whoops::Event file.

Burdening Your Mind Grapes

(For more on mind grapes, see #6 in this cracked article)

As anyone who's dealt with this kind of code can attest, organizing your code this way adds cognitive load. By violating the single responsibility principle, we make it more difficult for ourselves to find the methods we're interested in. The class's name starts to lose its meaning as its conceptual category becomes broader and broader until it becomes useless as an organizational unit. It's like trying to find the latest teen vampire romance novel at a bookstore only to find it in the computer section because the sexy, misunderstood male vampire love interest has an iphone.

And everyone knows that having to navigate multiple files in order to understand one use case is a huge pain in the ass. I mean, that's pretty much why Chris Granger is making Light Table, right?

The Refactoring

This github diff shows all the changes to DCI-ify the code.

As you can see, our Whoops::Event and Whoops::EventGroup classes are no longer violating the single responsibility principle. Now when you dig into those classes you no longer have to wade through code that's only tangentially related to the real purpose of those closses. We're no longer burdening our classes with knowledge that they shouldn't have, reducing coupling.

Additionally, all of the code related to handling a new event is now in one place, the Whoops::NewEvent class. This makes it much easier to understand completely everything that happens when a new event is handled. Rejoice!

I'm not sure how much more to say about the benefits of this refactoring. It feels a lot nicer to me, and it un-does the problems that existed earlier. Hopefully that's evident, but if it's not then please leave a comment or tweet me

The Promised Hand-Wringing

There are still some things that I'm not completely happy with in Whoops::NewEvent.

First, it basically looks one gigantic method that's been split up into tiny methods just so that I could name groups of related code. I don't know that there's much value in that.

Second, lines 13-15 look like they need some sort of explanation. It's especially not obvious why I'm setting archived to false.

Last, I'm not completely happy with the name NewEvent. If you were new to the project you'd probably ask yourself, "Uh... how is that not Whoops::Event.new?" I also considered "Whoops::NewEventHandler" but I was afraid that would summon Steve Yegge and he'd make me read one of his 10,000 word essays.

I'd love to get some thoughts on this! Whether or not this is a reasonable DCI refactoring, I think that DCI is a great tool and something OOP folks should investigate.

Comments