TDD your way to a richer domain model

Jared Carroll ·

A well established and accepted design principle in the Rails community is “skinny controller, fat model”. This involves placing an application’s domain logic in models and keeping the request handling controllers simple and sparse (i.e. “skinny”). The result is a more modularized, reusable, rich domain model. The following is an example of using TDD to achieve this style of design.

The Workflow

Let’s imagine an app that uses a 3rd party service to send SMS messages to its users. To no longer receive SMS messages from the site, a user would text ‘STOP’ to this 3rd party service. Upon receiving a ‘STOP’ SMS message from a user, the service performs an HTTP POST containing the user’s mobile phone number (in XML) to a callback url on our site. From here we can update that user’s SMS preferences.

Let’s start with the controller spec.

rails g controller sms_notifications
describe SmsNotificationsController do

  describe '#unsubscribe' do
    before do
      @sms_user = Factory :sms_user
      xml = <<-EOS      
       <?xml version="1.0"?>
       <phone_number>
        #{@sms_user.mobile_phone_number}     
       </phone_number>
      EOS
      request.env['RAW_POST_DATA'] = xml

      post :unsubscribe
    end

    it 'unsubscribes the user from SMS notifications' do
      @sms_user.reload
      @sms_user.sms_notifications.should_not be
    end

    it 'is successful' do
      response.should be_success
      response.body.should be_blank
    end
  end

end

Running the spec tells us our next step…

SmsNotificationsController#unsubscribe unsubscribes the user from SMS notifications
No route matches {:controller=>”sms_notifications”, :action=>”unsubscribe”}

Let’s implement just enough to figure out what to do next…

config/routes.rb

  ...

  resources :sms_notifications do
    collection do
      post :unsubscribe
    end
  end

  ...

And then run the spec again…

SmsNotificationsController#unsubscribe unsubscribes the user from SMS notifications
The action ‘unsubscribe’ could not be found for SmsNotificationsController

Ok great. Another small step…

class SmsNotificationsController < ApplicationController def unsubscribe end end [/sourcecode] And back to the spec...

SmsNotificationsController#unsubscribe unsubscribes the user from SMS notifications
Missing template sms_notifications/unsubscribe

Ok now its time to party. In order to get our specs passing our first response might be to implement something similar to the following:

class SmsNotificationsController < ApplicationController def unsubscribe document = Nokogiri::XML.parse request.raw_post phone_number = document.xpath('/phone_number').first.text user = User.find_by_phone_number phone_number user.unsubscribe_sms_notifications! head 200 end end [/sourcecode] This code will get our spec passing but its in the wrong place. This code is domain logic, it belongs in the model.

Being Lazy

Let’s rewrite the action but this time take a much lazier approach in our implementation.

class SmsNotificationsController < ApplicationController def unsubscribe user = User.find_by_sms_unsubscribe_xml request.raw_post user.unsubscribe_sms_notifications! head 200 end end [/sourcecode] Even though User doesn’t understand .find_by_sms_unsubscribe_xml or its instances #unsubscribe_sms_notifications!, I wrote this wishing these methods existed. By being lazy, I’ve defined a nice high-level interface to my domain. This is very similar to the way test first development helps you design an object’s interface. Our domain logic is now where it belongs and the models have become more interesting.

Running our specs tells us what to do next…

SmsNotificationsController#unsubscribe unsubscribes the user from SMS notifications
undefined method `find_by_sms_unsubscribe_xml’ for #

At this point, we go down another level and start the tdd process over again for User.find_by_sms_unsubscribe_xml. Once that’s passing, we come back up to the controller and run our specs again…

SmsNotificationsController#unsubscribe unsubscribes the user from SMS notifications
undefined method `unsubscribe_sms_notifications!’ for #

We then repeat the same process for User#unsubscribe_sms_notifications! before coming back up to the controller spec. The end result is a rich domain model with lazy, skinny controllers.