One of the biggest benefits of using Solidus as your eCommerce framework is that it’s designed to be customized. It comes with built in hooks, extension points, good documentation, and it’s also written in Ruby, a language that lets you reach into any class or module and modify it. However, this flexibility comes with a degree of risk—modifying code that you don’t own, such as the Solidus gem, can lead to hard-to-catch bugs when upgrading gems.
There’s a number of practices and general guidelines I’ve adopted that help make my customizations to Solidus (and other gems) more resilient to gem upgrades.
If you haven’t already, start by reading the “Customization” section of the official Solidus guide before reading this blog!
If you’re anything like me, you would prefer not to copy code from Solidus into your own app, and you don’t want to add more views to an already complex admin! You would just love to be able to customize Solidus with the built in hooks and events, or nice clean overrides that call super
and add your own functionality on top, or deface
overrides that hook into data-hook
attributes and cleanly add new elements to existing UI. And luckily, Solidus is a well designed framework that lets you that; most of the time.
However, when overriding classes deep within complex parts of Solidus, or modifying views that rely on JavaScript to function, I start running into serious issues when trying to customize behavior.
In situations like these I stop and think: how should I be overriding this?
Recently, I was tasked extending Solidus to meet a business requirement of a client. The requirement was:
We want administrators to be able to search for and override the user associated with an order.
The customer details page of an order already has a form which allows users to search for and fill in the “customer information” associated with an order.
My first thought on approaching this was,
Great! I can just add another search dropdown to this page, and configure it to override the user on the order instead of filling out the form with information.
However, I immediately discovered problems when digging into this approach.
The existing form is powered by a Backbone JavaScript file. These JavaScript files files are spread throughout Solidus, and are difficult (sometimes impossible) to extend. To modify them, you usually have to first copy the whole file into your codebase.
Spree.Views.Order.CustomerDetails = Backbone.View.extend({
initialize: function() {
this.billAddressView =
new Spree.Views.Order.Address({
model: this.model.get("bill_address"),
el: this.$('.js-billing-address')
});
//...
this.customerSelectView =
new Spree.Views.Order.CustomerSelect({
el: this.$('#customer_search')
});
...
this.render()
},
//...
The existing form has a user_id
field. It sets the user_id
to nil
if “Guest Checkout” is selected.
<%= hidden_field_tag :user_id, @order.user_id %>
onGuestCheckoutChanged: function() {
if(this.$('#guest_checkout_true').is(':checked')) {
this.model.set({user_id: null})
}
},
To add a searchable “Override Order User” field to the existing form, extensive changes need to be made to the original customer details code:
customer_details
JavaScript file needs to be completely overridden and extensively modified to accommodate another search form.user_id
field needs to support the existing behavior, and and the behavior of the new search feature.To add one field to this form, many aspects of the form and supporting code need to change in subtle ways.
This approach requires;
copying in Solidus code to your codebase,
making non-trivial modifications to a complex JavaScript flow, and
touching behavior that is not relevant to the feature.
If the JavaScript file changed upstream in Solidus, we would need to port and reconcile the changes with our modifications. Unless the developer in charge of doing the Solidus upgrade checks for copied files like this, these changes could get missed, which could cause strange and hard-to-debug issues.
One way to mitigate this is through testing, as I will discuss below. However, in this situation, since we copied over a complicated JavaScript flow, we would have to write a fair number of tests to ensure we have good coverage. Otherwise, we could accept the risk that this page could break for Admin’s during upgrades, or perform manual testing steps during upgrades.
With this solution, resilience during upgrades requires: a) extensive automated testing for existing Solidus features, or b) manual testing on an upgrade, to ensure that your override doesn’t break. This isn’t ideal.
The best practice is to only touch a small surface area when extending a third party gem like Solidus. Adding in another search box to the existing customer details form would look slick, and arguably make the most sense visually and conceptually, but it comes with big trade-offs for long term maintainability. Even though the user experience is less straightforward, this is a situation where creating new forms and views has benefits.
Here are some recommended ways you can extend Solidus for this feature request.
One approach is to add a button which opens a ‘Change User’ modal. This modal has it’s own form, with a similar search box to the customer details form. We can even copy some code from customer_details.js
to make it work with minimal effort! The key difference is that we aren’t replacing the existing customer_details.js
file in order to make modifications. This means that the only Solidus Code change that could break this update would be changes to the element that the button injected into on the form.
Here’s a full working example of this implementation
This implementation heavily relies on copying over the existing JavaScript from Solidus, but critically, not replacing this JavaScript with our own implementation. Instead, we adapt it to be used in new views in the modal. This allows us to take advantage of existing Solidus code for our custom user search, without risking breaking the existing customer search.
The key aspect to this implementation is the minimal surface area actually touched in Solidus–no existing Solidus files are fully copied and overridden, and the only place that Solidus is hooked into is a deface partial that adds the modal.
Another approach is moving this functionality to a different part of the app.
These approaches don’t require extensively overriding Solidus in your application, or making changes to complex Solidus logic.
This example shows that sometimes the most intuitive solution can lead to implementations that are difficult to maintain through upgrades and prone to causing bugs.
However, no matter how simple your override is, the risk of breaking changes during an upgrade is always higher without testing coverage.
Fundamentally, customizing a third-party gem comes with a degree of risk. Let’s break down this example of customizing the Spree::Variant
class.
This customization was needed so preorder variants could always be in stock and orderable, even if no “real” stock existed.
module Spree
module VariantExtension
extend ActiveSupport::Concern
#...
def can_supply?(quantity = 1, stock_locations = nil)
if preorder?
true
else
super(quantity, stock_locations)
end
end
Spree::Variant.prepend(self)
end
end
For this override to work without breaking existing Solidus code, the following must be true:
can_supply?
.quantity
and stock_locations
, and have the same default values.Spree::Variant
class.This is a fairly simple and well-implemented override of a public method, but it can still be broken by upstream changes in Solidus. Overrides of private methods and other more complex overrides are more likely to break when upgrading Solidus.
Testing is an important part of mitigating this risk. Tests notify you when the behavior introduced or changed by your override breaks. Generally, you should test three things when overriding Solidus (or other third party gems).
You don’t want to break existing Solidus behaviour. Start by building an override with one method that calls super, and then copy in the specs from Solidus into your app, so that they run on CI.
When you introduce an override, you have taken ownership of the code. Test it accordingly.
# app/models/overrides/spree/variant_override.rb
module Spree
module VariantExtension
extend ActiveSupport::Concern
#...
def can_supply?(quantity = 1, stock_locations = nil)
super(quantity, stock_locations)
end
Spree::Variant.prepend(self)
end
end
# spec/models/spree/variant_spec.rb
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Spree::Variant, type: :model do
let!(:variant) { create(:variant) }
#...
# Note: Some of these specs were originally copied from Solidus
describe "#can_supply?" do
it "calls out to quantifier" do
expect(Spree::Stock::Quantifier).to receive(:new).and_return(quantifier = double)
expect(quantifier).to receive(:can_supply?).with(10)
variant.can_supply?(10)
end
#...
end
#...
end
Next, write a unit test for the new behavior.
# app/models/overrides/spree/variant_override.rb
module Spree
module VariantExtension
extend ActiveSupport::Concern
#...
def can_supply?(quantity = 1, stock_locations = nil)
if preorder?
true
else
super(quantity, stock_locations)
end
end
Spree::Variant.prepend(self)
end
end
# spec/models/spree/variant_spec.rb
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Spree::Variant, type: :model do
let!(:variant) { create(:variant) }
#...
# Note: Some of these specs were originally copied from Solidus
describe "#can_supply?" do
it "calls out to quantifier" do
expect(Spree::Stock::Quantifier).to receive(:new).and_return(quantifier = double)
expect(quantifier).to receive(:can_supply?).with(10)
variant.can_supply?(10)
end
#...
context 'when the variant is a preorder variant' do
let(:variant) { create(:variant, preorderable: true) }
it 'returns true' do
expect(variant.can_supply?).to be true
end
end
end
#...
end
Finally (and most importantly), test the custom behavior in the context of the larger system. This can be done with a browser-driving or integration test.
In this example, it’s appropriate to write a browser-driving test that verifies that when an item is in preorder, it can be added to your cart.
This is the most important test to have because it alerts you to Solidus deleting or changing how the overridden method is used within the gem.
If Solidus stops using can_supply?
to determine whether something can be added to your cart, the unit test will still pass, but the browser-driving test will fail. If the method you are modifying has multiple uses throughout Solidus, consider testing the critical ones. The most important thing is that you test that the behavior you’ve changed works at a high level.
Solidus (and other gems) can be incredibly complicated, and do not always have the best documented changelogs for each upgrade. A robust test suite greatly reduces the risk that changes in Solidus upgrades break your overrides. This allows you to upgrade quickly and confidently, keeping your store modern and secure.
It’s easy to break an existing interface when overriding methods. Here are two common ways mistakes to look out for!
When overriding a method, unless the intention is to change a method’s return value, always return the original value, e.g. super
.
Example of a override that changes a method’s return unintentionally
class OrderUpdater < Spree::OrderUpdater
def recalculate
super
order.update_deposit
# Now the result of `order.update_deposit` is returned!
# This changes the method's interface.
end
What you should do instead
class OrderUpdater < Spree::OrderUpdater
def recalculate
return_val = super
order.update_deposit
return_val
end
There are two possible exit strategies of a method in Ruby:
a. returning a value, including nil
b. throwing an error
When overriding a method, it’s possible to change the exit strategy of a method unintentionally. For example, by throwing an error in a method that never had an error thrown before, various uses of the method throughout Solidus not designed to catch the error may break. The method might have been designed to return a false
or nil
value in case of an error, or set an error on an associated model.
Unless the consequences of changing an exit strategy are known, throwing exceptions from methods that are meant to return (and vice versa) can cause unexpected behavior.
These are not hard and fast rules. These are examples of how to navigate the complex problem of augmenting a gem like Solidus in ways that will last for years (and major version releases) to come. There are always exceptions, and learning what is most appropriate for your codebase, development process, and reliability requirements will help you customize Solidus more effectively. If you ever need inspiration, look to the wide assortment of Solidus extensions on GitHub which all add functionality to Solidus.
Good luck, and have fun monkey patching!