Liquid Templating Engine is an awesome technology and with the power of gem ‘liquid’, everybody can start using without much hassles. I saw many projects used this gem but sadly most of them are quite bad. In this tutorial, I’ll go through a bad example and show you how to refactor it.

The smell

Below code is for a report generation function for a car dealer Rails app.

Let’s see the codes:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 # app/models/car_issue.rb class CarIssue < ActiveRecord : :Base belongs_to :car liquid_methods :description , :reference_number end # app/models/car.rb class Car < ActiveRecord : :Base has_many :car_issues belongs_to :customer liquid_methods :car_make , :owner , :dealer_contact_details def owner customer . full_name end def car_make " #{ brand } #{ model } MY #{ manufacturer_year } " end def dealer_contact_details <<- CONTACT Uber Dealer 9 Sesame St Emo Town CONTACT end end # app/models/customer.rb class Customer < ActiveRecord : :Base liquid_methods :full_name def full_name " #{ first_name } #{ last_name } " end end # app/services/car_report_generation.rb class CarReportGeneration def initialize ( car ) @car = car end def template <<- TEMPLATE Dear {{ car.owner }}, We've performed inspection on your {{ car.car_make }} and found following issues: {% for issue in car.issues %} * {{ issue.reference_number }} - {{ issue.description }} {% endfor %} Please call us back for quotes Sincerely yours {{ car.dealer_contact_details }} TEMPLATE end def generate_report Liquid : :Template . parse ( template ) . render 'car' => @car end end

As you can see above, the main logic is CarReportGeneration#generate_report which render the report template using Liquid to populate fields.

What’s so stink about the above code? In fact, I think it’s perfectly fine. However should the report requires more details, user will create more methods within Car model to serve as report details getter and this could get very ugly quickly as the coupling emerges more clearly.

Refactoring

We will take many little steps.

The first step is to reduce the coupling between our models and Liquid. As you can see above that the code exposes methods to Liquid with method liquid_methods . This method meta-programmingly create for you a class which is a subclass of Liquid::Drop then create instance methods that matches the parsed method names.

So our Car’s liquid_methods :car_make, :owner, :dealer_contact_details would do following implicit things:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 Car . class_eval do def to_liquid Car : :LiquidDropClass . new ( self ) end end class Car :: LiquidDropClass < Liquid : :Drop def initalize ( object ) @object = object end def owner @object . customer . full_name end def car_make @object . car_make end def dealer_contact_details @object . dealer_contact_details end end

We see that the magic lies in method to_liquid which point to a Liquid::Drop class. This special object is what Liquid template takes in to render the template.

Now we could replicate the logic easily. See the code below:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 # app/models/car.rb class Car < ActiveRecord : :Base has_many :car_issues belongs_to :customer def to_liquid CarMergeField . new ( self ) end end # app/merge_fields/car_merge_field.rb class CarMergeField < Liquid : :Drop attr_reader :car def initalize ( car ) @car = car end def owner car . customer . full_name end def car_make " #{ car . brand } #{ car . model } MY #{ car . manufacturer_year } " end def dealer_contact_details <<- CONTACT Uber Dealer 9 Sesame St Emo Town CONTACT end end

By moving all Liquid-related methods to a separate class, we leave Car model clean and slim, well with a cost of one function, that is Car#to_liquid , but I guess we could live with that for now.

But that’s the end of refactoring yet, we could see that CarMergeField#dealer_contact_details should not belong to Car model and would be shared between many reports in the future.

So we create a new class for that:

1 2 3 4 5 6 7 8 9 10 11 12 class DealerMergeField < Liquid : :Drop def contact_details <<- CONTACT Uber Dealer 9 Sesame St Emo Town CONTACT end end

and make sure we also remove CarMergeField#dealer_contact_details and update our template:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 # app/services/car_report_generation.rb class CarReportGeneration do # ... def template <<- TEMPLATE Dear {{ car.owner }}, We've performed inspection on your {{ car.car_make }} and found following issues: {% for issue in car.issues %} * {{ issue.reference_number }} - {{ issue.description }} {% endfor %} Please call us back for quotes Sincerely yours {{ dealer.contact_details }} TEMPLATE end def generate_report liquid_drops = { 'car' => @car , 'dealer' => DealerMergeField . new } Liquid : :Template . parse ( template ) . render ( liquid_drops ) end end

Please pay attention closely to the CarReportGeneration#generate_report , you could see that I parse in new liquid drop dealer which is an object of DealerMergeField . FYI, the liquid does not have to tie to a model. We now could carry on and apply the same technique for model CarIssue.

Yet, I am not satisfied, we could push abit further by extraction common liquid rendering logic into its own class and encourage reusability of this class for other reports.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 # app/services/report_generation.rb class ReportGeneration attr_reader :report def initialize ( report ) @report = report end def generate_report Liquid : :Template . parse ( report . template ) . render ( report . liquid_drops ) end end # app/reports/car_report.rb class CarReport def initialize ( car ) @car = car end def template <<- TEMPLATE Dear {{ car.owner }}, We've performed inspection on your {{ car.car_make }} and found following issues: {% for issue in car.issues %} * {{ issue.reference_number }} - {{ issue.description }} {% endfor %} Please call us back for quotes Sincerely yours {{ dealer.contact_details }} TEMPLATE end def liquid_drops { 'car' => @car , 'dealer' => DealerMergeField . new } end end

and to use it we do:

1 2 car_report = CarReport . new ( @car ) ReportGeneration . new ( car_report ) . generate_report

Conclusion

Never let a gem manipulate you. If you see a gem makes you do bad things, then you should dig deeper. Even writing new thing your own is not a bad solution. If you don’t have time for that, make sure you write abstract method that could help you untangle the coupling later.

I hope this is useful to some. I really welcome feedbacks.

Keep on learning folks!