Blog
0 pixels scrolled
  • Home
  • Work
  • Services
  • About
  • Blog
  • Contact
Making Solidus Customizations More Resilient
Noah Silvera on September 30, 2024
Making Solidus Customizations More Resilient
Noah Silvera on September 30, 2024
Posted in Solidus
← Back to the blog

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!

When to override, and when to augment

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?

An argument for new views and forms

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.

A screenshot of the customer details section of an order in the Solidus Admin

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.

Problem One: Backbone Javascript

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.

customer_details.js

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()
  },
 //...

Problem Two: The new feature implementation conflicts with existing code

The existing form has a user_id field. It sets the user_id to nil if “Guest Checkout” is selected.

customer_details/_form

<%= hidden_field_tag :user_id, @order.user_id %>

customer_details.js

  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:

  • The customer_details JavaScript file needs to be completely overridden and extensively modified to accommodate another search form.
  • The 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.

How does this impact upgradability?

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.

Well, what should we do?

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.

Add a button to the form which opens a modal

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

An animated gif demonstrating a modal that changes an order's user in the Solidus Admin

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.

Adding the user override functionality to a different page in Solidus

Another approach is moving this functionality to a different part of the app.

  • There could be a separate order tab titled ‘User Editing’ that has a form for overriding the user.
  • The functionality could exist on the user profile page in the admin.
  • The functionality could exist in an inline dropdown in the order summary that replaces the user’s name.

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.

Always test your overrides

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:

  • The method must be named can_supply?.
  • The method’s arguments must be quantity and stock_locations, and have the same default values.
  • The method must be located in the Spree::Variant class.
  • The other parts of Solidus that use this method to determine whether an item can be ordered must continue to rely on this method.

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

1. Test the built in Solidus Behavior

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

2. Test your new behavior at the unit level

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

3. Test your new behavior in the context of the larger Solidus system

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.

Footnote - Don’t break existing interfaces!

It’s easy to break an existing interface when overriding methods. Here are two common ways mistakes to look out for!

Forgetting to return the result of super

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

Changing the exit strategy of a method

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.

In Conclusion…

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!

Work ServicesAboutBlogCareersContact


Privacy policyTerms and conditions© 2024 Super Good Software Inc. All rights reserved.