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?
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.
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.
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