Inheritance in Ruby on Rails Using Plain Ole’ Ruby Objects
By: Ryan Francis / October 14, 2015
Using inheritance in Ruby is extremely powerful and can greatly reduce complexity in your code. In this blog post, I show you how to dry up your code with Plain Ole’ Ruby Objects (PORO’s) that leverage inheritance.
Example Use Case: Filtering Apartment Listings
Let’s say we have an apartment listings website. Users can search for apartments in their area and refine the search with filters like # of bedrooms, max price, and lease start date.
User Chooses Number of Bedrooms (images courtesy of apartmentlist.com)
User Sets Max Rent
User Selects Lease Start Date
When a user submits the form, the controller params will come in like:
{ search: { bedrooms: "3", max_price: "1000.00", lease_start_date: "11/01/2015" } }
As simple as this form is, we still have some work to do to display the right results.
- First, we need to clean the data before we can query our database. For example, we need to convert “09/01/2015” to a Ruby Date object that our system can understand.
- Second, we need to implement some queries to pull apartments from the database and filter the results.
The Quick Solution
The quickest and most obvious solution is to plop our code right into the controller:
# controllers/search_controller.rb class SearchController < ApplicationController def apartments bedrooms = params[:search][:bedrooms].to_i max_price = params[:search][:max_price].to_f lease_start_date = Date.parse(params[:search][:lease_start_date]) @apartments = Apartment.where(bedrooms: bedrooms).where("price <= ?", max_price).where(lease_start_date: lease_start_date) end end
While simple now, this code will get unweildy quickly. Imagine we want to implement the following filters next:
User Selects Ammenities
I’ll save you the pain of showing the code required to implement these new filters, but you can imagine how long this controller action would become. It’s time to move the form data parsing and apartment filtering into a service object.
Service Object
Let’s create an object to handle the filtering of our results.
# controllers/search_controller.rb class SearchController < ApplicationController # mmm, a controller action I can read def apartments @apartments = Forms::FilterApartments.new(params[:search]).apartments end end
# classes/forms/filter_apartments.rb class Forms::FilterApartments attr_reader :bedrooms, :max_price, :lease_start_date def initialize(args = {}) @bedroooms = args[:bedrooms].to_i @max_price = args[:max_price].to_f @lease_start_date = string_to_date(args[:lease_start_date]) end def apartments @apartments = Apartment.where(bedrooms: bedrooms) .where("price <= ?", max_price) .where(lease_start_date: lease_start_date) end private def string_to_date(date_string) Date.parse(date_string) end end
This service object is certainly an enhancement. It provides a place where we can perform parsing and filtering logic and allows the controller to do what it does best: set data for our views to access.
Another side benefit is that testing this class would be very straight forward.
People Rent Houses Too!
Our app has taken off and users want another page to see house listings. Let’s start to write the controller action and corresponding service object to filter results for houses. Unlike apartments, houses come with some additional filtering options: acreage, and has_front_porch
# controllers/search_controller.rb class SearchController < ApplicationController def apartments @apartments = Forms::FilterApartments.new(params[:search]).apartments end def houses @houses = Forms::FilterHouses.new(params[:search]).houses end end
# classes/forms/filter_houses.rb class Forms::FilterHouses attr_reader :bedrooms, :max_price, :lease_start_date, :acreage, :has_front_porch def initialize(args = {}) @bedroooms = args[:bedrooms].to_i @max_price = args[:max_price].to_f @lease_start_date = string_to_date(args[:lease_start_date]) @acreage = args[:acreage].to_f @has_front_porch = string_to_boolean(args[:has_front_porch]) end def houses @houses = House.where(bedrooms: bedrooms) .where("price <= ?", max_price) .where(lease_start_date: lease_start_date) .where("acreage >= ?", acreage) .where(has_front_porch: has_front_porch) end private def string_to_date(date_string) Date.parse(date_string) end def string_to_boolean(boolean_string) boolean_string == "true" end end
Man, the FilterHouses service object seems very similar to the FilterApartments service object. Both are filtering by number of bedrooms, max_price, and lease_start_date. Both are converting the incoming lease_start_date string to a date as well.
Intuitively, we know that searching for houses and searching for apartments are fundamentally similar experiences. So it is no surprise to me we are seeing these commonalities in the code.
How can we dry this up? Let’s give inheritance a shot.
Implementing Inheritance
FilterApartments and FilterHouses Inherit From FilterProperties
Inheritance is really useful when two things are very similar. For example, a house and an apartment are two very similar entities. Inheritance allows us to put the common code between these two entities in one shared class, and the code specific to each the house and the apartment in those respective classes.
# classes/forms/filter_properties.rb class Forms::FilterProperties attr_reader :bedrooms, :max_price, :lease_start_date def initialize(args = {}) @bedroooms = args[:bedrooms].to_i @max_price = string_to_float(args[:max_price]) @lease_start_date = string_to_date(args[:lease_start_date]) after_initialize(args) # implemented by houses and apartments filters end def results @results = model.where(bedrooms: bedrooms) .where("price <= ?", max_price) .where(lease_start_date: lease_start_date) end private def after_initialize(args = {}) # implemented by subclasses end def model # required method for subclasses # need to know what table to find the data fail "#{self.class.name} must implement model method." end def string_to_date(date_string) Date.parse(date_string) end end
# classes/forms/filter_properties/apartments.rb class Forms::FilterProperties::Apartments < Forms::FilterProperties private def model Apartment end end
# classes/forms/filter_properties/houses.rb class Forms::FilterProperties::Houses < Forms::FilterProperties attr_reader :acreage, :has_front_porch def results super # runs results method in Forms::FilterProperties @results = @results.where("acreage >= ?", acreage) .where(has_front_porch: has_front_porch) end private def after_initialize(args = {}) @acreage = args[:acreage].to_f @has_front_porch = string_to_boolean(args[:acreage]) end def model House end def string_to_boolean(boolean_string) boolean_string == "true" end end
With these three classes in place, we have abstracted the commonalities between FilterApartments and FilterHouses into one class, FilterProperties. Further, since the Houses filter has every filter that Apartments has plus acreage and has_front_porch, we see that the Houses filter only needs to worry about acreage and has_front_porch.
In my opinion, implementing inheritance in this situation is absolutely critical. What happens, for example, if the bedrooms filter changes? Maybe users want to see apartments and houses with more than 3 bedrooms instead of filtering exactly on the bedroom number. This type of change would be cumbersome if the logic for filtering bedrooms was in both the Apartments filtering class and the Houses filtering class (let alone separate controller actions if we didn’t implement service objects).
You may be saying, “Why not implement Single Table Inheritance (STI)?” Actually, I would in fact use STI here so that we could store all properties in one table called “properties.” However, I often use POROs with inheritance to handle the form parsing and at least abstract those common parsing needs into a shared class.
Here is what STI would look like in our Rails modeling:
# models/property.rb class Property < ActiveRecord::Base end # models/apartment.rb class Apartment < Property end # models/house.rb class House < Property end
With this modeling, we would not need the “model” method in our FilterProperties service object. To me, a house and an apartment are too similar of entities to not inherit from a common model, which intuitively is a property. Further, I can envision a situation in which both apartments and houses are shown to users in the same list.
Ready to Build Something Great?
Partner with us to develop technology to grow your business.