Skip to main content

Customizing the core

This guide will teach you how to customize different aspects of Solidus' business logic, as well as the different techniques for doing so.

Customization strategies

Most Solidus stores don't stop at customizing the storefront: in fact, if all you need is a custom storefront, perhaps Solidus is not a good choice in the first place. Solidus users want to be able to customize every single aspect of their store, not just how the store appears to customers but also what happens under the hood when customers browse it and place an order.

Because Solidus is built on Ruby and Ruby on Rails, there are no limits to what can be customized. You can literally change every single aspect of the framework's business logic however you see fit, either through built-in customization hooks or Ruby's powerful meta-programming features.

How you customize Solidus will have implications for the stability and maintainability of your store, so it's good to know the set of tools at your disposal and make an informed decision depending on your specific use case. In most cases, you'll want to adopt a mix of these approaches.

Using built-in hooks

Solidus provides a rich and powerful API for customizing different aspects of your store's business logic. By defining which classes get called to perform certain tasks in your store, you have the option to either enrich or completely replace Solidus' default functionality.

The Spree::AppConfiguration class has a list of all the service object classes that can be natively customized. Look for through the source code of that class and see if there's an option that resembles what you need to do. If yes, bingo!

info

Spree::AppConfiguration is not the only configuration class that contains service objects. Before resorting to other customization methods, search through Solidus' source code and see if there are any other options that allow you to replace the class you need.

For instance, Solidus merges a user's "guest" cart with the cart associated to their user account when they sign in. Let's suppose you don't like this default behavior, and would like to keep the two carts separate at all times instead, to avoid confusion for the user. You can see there is an option in the configuration that allows us to control precisely this behavior:

solidus/core/lib/spree/app_configuration.rb
# Allows providing your own class for merging two orders.
#
# @!attribute [rw] order_merger_class
# @return [Class] a class with the same public interfaces
# as Spree::OrderMerger.
class_name_attribute :order_merger_class, default: 'Spree::OrderMerger'

Let's also take a look at the default Spree::OrderMerger class to understand what public API Solidus expects us to implement in our custom version:

solidus/core/app/models/spree/order_merger.rb
module Spree
class OrderMerger
# ...

def initialize(order)
# ...
end

def merge!(other_order, user = nil)
# ...
end

# ...
end
end

As you can see, the order merger exposes two public methods:

  • #initialize, which accepts an order.
  • #merge!, which accepts another order to merge with the first one and (optionally) the current user.

Equipped with this information, we can now write our "nil" order merger:

app/models/amazing_store/nil_order_merger.rb
module AmazingStore
class NilOrderMerger
attr_reader :order

def initialize(order)
@order = order
end

def merge!(other_order, user = nil)
order.associate_user!(user) if user
end
end
end

Finally, now that we have the new merger, we need to tell Solidus to use it:

config/initializers/spree.rb
Spree.config do |config|
# ...
config.order_merger_class = 'AmazingStore::NilOrderMerger'
end

Restart your application server, and Solidus should start using your shiny new order merger!

danger

When overriding critical functionality, you may also want to make sure that your feature is working correctly in integration. The unit test we have written, for instance, doesn't guarantee in any way that our custom order merger responds to the expected public API and will not break as soon as Solidus tries to call it.

Using overrides

Solidus is a large and complex platform and, while new built-in customization hooks and events are introduced all the time to make the platform easier to extend, there may be situations where Solidus doesn't provide an official API to customize what you need. When that's the case, Ruby's meta-programming features come to the rescue, allowing you to extend and/or override whatever you want.

As an example, suppose you want to introduce an environment variable that allows you to temporarily make all products unavailable in the storefront.

When you look at Solidus' source code, you will notice that Spree::Product already has an #available? method to control a product's visibility. This is the original implementation:

# Determines if product is available. A product is available if it has not
# been deleted and the available_on date is in the past.
#
# @return [Boolean] true if this product is available
def available?
!deleted? && available_on&.past? && !discontinued?
end

As you can see, there is no "clean" way we can extend this method: no built-in customization hooks or events that can help us.

However, we can still use plain old Ruby and the power of Module#prepend. If you're not familiar with it, #prepend is a method that allows us to insert a module at the beginning of another module's ancestors chain. Think of it as taking a module A and placing it "in front" of another module B: when you call a method on module B, Ruby will first hit module A and then continue down the chain of ancestors.

In the Solidus ecosystem, we call overrides to the modules that are prepended. Overrides are usually named in a descriptive way that expresses how the override extends the original class.

info

The Solidus ecosystem used to rely heavily on #class_eval for overrides, but #prepend is a cleaner and more easily maintainable approach. You may see old guides, tutorials and extensions still using #class_eval, but you should know this is a deprecated pattern.

danger

If you're not yet in Ruby 3 and you're prepending a module, take note that if Rails includes the module before the prepend is called, then Rails might not be able to include the prepended behavior. This might happen if you're prepending a views helper or an ActiveSupport concern. For these cases, you might have no choice but to use #class_eval to override the module. For more information, please see Module.prepend does not work nicely with included modules.

To begin with, you need to set up the directory where you'll place your overrides so that Rails can pick them up:

config/application.rb
overrides = "#{Rails.root}/app/overrides"
Rails.autoloaders.main.ignore(overrides)
config.to_prepare do
Dir.glob("#{overrides}/**/*.rb").each do |override|
load override
end
end

In our example, we can customize the Spree::Product#available? method by writing a module that will be prepended to Spree::Product. Here's our AddGlobalHiddenFlag override:

app/overrides/amazing_store/spree/product/add_global_hidden_flag.rb
module AmazingStore
module Spree
module Product
module AddGlobalHiddenFlag
def available?
ENV['MAKE_PRODUCTS_UNAVAILABLE'] == false && super
end

::Spree::Product.prepend self
end
end
end
end

As you can see, we are not only able to override the default #available? implementation, but we can also call the original implementation with super. This allows you to decide whether you want to extend the original method or completely override it.

danger

You should always prefer customizing Solidus via public, standardized APIs such as the built-in customization hooks and the event bus, whenever possible. When you use a supported API, it's much less likely your customization will be broken by a future upgrade that changes the part of the code you are overriding.

Using the event bus

Please, take a look at the Subscribing to events chapter for a complete description of the Event Bus on Solidus.