hacker / welder / mechanic / carpenter / photographer / musician / writer / teacher / student

Musings of an Earth-bound carbon-based life form.

Consider that we have a Rails monolith that has been around for a long time and over the years has accumulated a large amount of technical-debt. Features that were not finished, features that nobody uses; everybody knows a software stack like this somewhere, it’s not unique to Rails.

As time goes by, folks are using ActiveRecord to talk to a database that stores their data. Happily, they go about adding models and building relationships between those models, life is good! The application has customers, the customers are getting new features, the user base is growing, life is great! Then, one day, they reach a tipping point; the database that is storing the data becomes unable to service the needs of the user base. So one database becomes two, data becomes partitioned, some relationships become severed, and life marches on.

Then, two databases becomes three, three becomes four and so on until this application has hundreds of thousands of lines of code, 8 different databases and a whole host of barnacles hanging from it, pulling it down to its death.

How did this happen? How can we right the ship? Where do we go from here?

The shackles of ActiveRecord

ActiveRecord is a double-edged sword; it makes modeling your data easy, even trivial! This is great for productivity and helps to get code out the door faster. This is really a good way to get new features deployed quickly but can lead to some bad practices. Consider the following:

# models/user.rb
has_many :friends, through: 'User'
has_many :movies, through: 'Review'
# controllers/user_controller.rb
def show
  @user = User.find(params[:id])
end
<!-- views/user/show.html.erb -->
<% @user.friends.movies.each do |friends_movie| %>
  <tr>
   <td><%= friend_movie.title %></td><td><%= friends_movie.rating %></td>
  </tr>
<% end %>

This is great, as long as all of your data is stored inside of a single database; making these requests is reasonably fast and things will “just work”.

fast forward 10 years

Now you have millions of customers, billions of data points and things no longer fit into a nice little box. Your Rails application has grown to dozens if not more than a hundred models, controllers and views, and you have code like this all over the place.

Problem 1 - Data source coupling

One of the most glaring issues with ActiveRecord is that your models descend from the ActiveRecord model, which means that your models are implicitly tied to ActiveRecord’s data-storage implementation.

Problem 2 - Lack of knowledge around what’s happening

A Proposed Solution

require 'services/review_service'

class ReviewsController
  def show
    # Load data using service façade
    @review = ReviewService.review_from_id(params[:id])
  end

  def update
    review = ReviewService.review_from_id(params[:id])
    review.weight = 500
    ReviewService.save(review)
  end
end

A simple Rails controller

# Service data source, uses AR for now
class ReviewService

  def self.save(review_data)
    active_record_review = Review.find(review.id)
    # merge data in review object into active_record_review
    active_record_review.save
  end

  def self.review_from_id(review_id)
    active_record_review = Review.find(review_id)
    review_data_from_ar_object(active_record_review)
  end

  private
  def review_data_from_ar_object(review)
    ReviewData.new(
      id: review.id,
      rating: review.rating,
      weight: review.weight,
      read_at: review.read_at.to_s
    )
  end

end

A service-interface to fetch data using AR and convert it to a data-model

# Models are not tied to where they are stored
class ReviewData
  attr_accessor :id, :rating, :weight, :read_at
  def initialize(review_hash)
    @id      = review_hash[:id]
    @rating  = review_hash[:rating]
    @weight  = review_hash[:weight]
    @read_at = Date.parse(review_hash[:read_at])
  end
end

Data-only model

# Service data source, using an API client
class ReviewService

  @api_client = ReviewService::Api.new(...)

  def self.save(review_data)
    api_object = api_object_from_review_data(review_data)
    @api_client.save(api_object)
  end

  def self.review_from_id(review_id)
    api_review = @client.find_review_by_id(review_id)
    review_data_from_api_object(api_review)
  end

  private
  def review_data_from_api_object(review)
    ReviewData.new(
      id: review.id,
      rating: review.rating,
      weight: review.weight,
      read_at: review.read_at.to_s
    )
  end

end

A service-interface to fetch data using AR and convert it to a data-model