Mountains

Code is Sisyphean

Introducing common API with Aliases

  • Refactoring Rails
  • Scenario

    Let’s say that you have an application that has three very similar models, CompactDisc, CassetteTape, and VinylRecord. They all have very similar behaviors, and are all related to one another, though they each have separate tables. STI may or may not be possible.

    Code smell Similar to “data clump”, “feature envy”; though in this case it’s models that are similar enough to always be aggregated together.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    # app/models/compact_disc.rb
    class CompactDisc < ApplicationRecord
      has_many :tracks
    end
    
    # app/models/cassette_tape.rb
    class CassetteTape < ApplicationRecord
      has_many :songs, through: :sides
    end
    
    # app/models/vinyl_record.rb
    class VinylRecord < ApplicationRecord
      has_many :grooves, through: :record_sides
    end
    

    They might be collected together like this (where it’s happening isn’t really important):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    @compact_discs = CompactDisc.all
    @cassette_tapes = CassetteTape.all
    @vinyl_records = VinylRecord.all
    
    (@compact_discs + @cassette_tapes + @vinyl_records).each do |album|
      case album.class
        when CompactDisc
          render partial: "tracklist", collection: album.tracks
        when CassetteTape
          render partial: "liner_notes", collection: album.songs
        when VinylRecord
          render partial: "tracklist", collection: album.grooves
      end
    end
    

    Whether or not this could actually be refactored into an STI relationship is unknown, but we will assume it cannot be, for one reason or another. A case statement, particularly with type-checking, is generally a code smell on its own.

    Let’s assume that what we would like the block above to look like is this:

    1
    2
    3
    4
    5
    6
    7
    
    @compact_discs = CompactDisc.all
    @cassette_tapes = CassetteTape.all
    @vinyl_records = VinylRecord.all
    
    (@compact_discs + @cassette_tapes + @vinyl_records).each do |album|
      render partial: "album", object: album
    end
    

    and that the _album.html.erb partial looks like this (just to show that we’re not just hiding the type checking). This is simplified for clarity.

    1
    2
    3
    4
    
    # app/views/albums/_album.html.erb
    album.songs.each do |song|
      render partial: "song", object: song
    end
    

    The most direct change would be to modify the association name, but because this is a legacy app, we want to make the least-impactful change. That We can use the :alias method here. So we might change the models from earlier to look like:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    # app/models/compact_disc.rb
    class CompactDisc < ApplicationRecord
      has_many :tracks
      alias :songs, :tracks
    end
    
    # app/models/cassette_tape.rb
    class CassetteTape < ApplicationRecord
      has_many :songs, through: :sides # unchanged
    end
    
    # app/models/vinyl_record.rb
    class VinylRecord < ApplicationRecord
      has_many :grooves, through: :record_sides
      alias :songs, :grooves
    end
    

    We can assume that Track and Song quack in similar enough ways that they can function interchangeably in the _song partial. (If not, additional alias can be used)