There are dependencies between gems and the platforms that use them. In scenarios where the platforms have the data and the gem has the knowledge, there is a direct circular dependency between the two and both need to talk to each other. I’ll show you how we used the Repository pattern in Ruby to remove that circular dependency and help us make gems thin and stateless. Plus, I’ll show you how using Sorbet in the implementation made our code typed and cleaner.
The Use Case
Shopify Core is a Ruby on Rails monolith that loads the Shopify admin along with other services such as checkout, APIs, etc. Hundreds of developers work with a continuous deployment cycle that ultimately results in a platform used by millions of merchants.
The Storefront Renderer is a fast, server-side rendering engine written in Ruby. It handles storefront traffic for merchants on the Shopify platform. It does one thing and does it well: serve storefront responses as quickly as possible. It's entirely separate from the Shopify Core monolith.
Both applications are independent and have many features in common. One of them is calculating the price for Products and Variants given specific data. The result can be as simple as a value stored in a database table column or as complex as a calculation that requires multiple database queries with heavy application logic. This logic is found in an internal gem that both Shopify Core and the Storefront Renderer (from now we will call them the "consumers") use. For more details about Storefront Renderer I suggest reading our post How Shopify Reduced Storefront Response Times with a Rewrite.
The gem follows two main principles: performance and accuracy. The price needs to be calculated rapidly and it must be correct. It has a main entry point class (
PricingEngine::Engine) with functions that are called from the consumers. Most of the functions require data that live in the consumer. For instance, one of the functions calculates the variants’ prices in a specific context (
calculate_prices_for_variants). It receives data describing the buyer context (such as country), applies some complex logic, and returns a response containing the product variants' prices for that specific buyer. The data is owned and stored by each consumer, it can be in different shapes and in different data sources based on their individual needs.
There’s a strong dependency between the gem and the consumers. The consumers have the data and the gem has the “knowledge”. This implies that there’s a circular dependency between the two and that both have to talk to each other.
The gem shouldn’t know how consumers execute the queries to get the data needed. The consumers might need to talk to other external services to get the information needed. This is where the Repository pattern comes into play.
The Repository PatternWe need to make sure:
- The calculation logic lives in the gem.
- The gem is stateless.
- The data is stored in the consumer and only the consumer knows how to access it.
- The gem doesn’t ask the consumer directly to get the data.
Fortunately, the well-known Repository pattern can be used to solve this problem:
"A Repository mediates between the domain and data mapping layers, acting like an in-memory domain object collection.
Conceptually, a Repository encapsulates the set of objects persisted in a data store and the operations performed over them, providing a more object-oriented view of the persistence layer. Repository also supports the objective of achieving a clean separation and one-way dependency between the domain and data mapping layers." (Edward Hieatt and Rob Mee Object-Relational Metadata Mapping Patterns 2003, p. 323.)
If we define the actions the gem needs from the consumer and list them in one (or more!) interface class, and then ask the consumer to implement that class and pass it to the gem, we would remove the direct circular dependency!
We define a contract between the gem and the consumers. The Repository implementation in the consumer accepts and returns objects that the gem knows how to handle. This contract is defined by the gem and used by the consumer within the Repository implementation.
The Implementation Process
Step 1: Define the Contract
In our very first step, we define the contract between the gem and the consumer. In the gem, we define common domain object types and the functions (parameters and return types) required to manipulate them. The common domain objects live under the Schema module and can be accessed from the consumers. We also define a common Interface class in the gem where each repository in the consumers implements, we call it
PricingRepositoryInterface. The interface and the Schema look like this:
In our case, we used only one repository but you can group functions in different repositories and call them accordingly.
Step 2: Create the Classes That Implements the Interface(s).
The second step is creating the implementations of the Repository Interface in each consumer. Each repository inherits from the interface
PricingRepositoryInterface defined in the gem in Step 1. The classes implementation in both consumers looks like this:
Step 3. Pass the Implementation to the Gem.
The next step is passing the implementation class from the consumer to the gem. There are different ways to do it, one of them is using another pattern: Dependency Injection. There are multiple ways to use this pattern, in this example we use what Martin Fowler calls Constructor Injection. We add in the constructor of the gem class’s entrypoint (
PricingEngine::Engine) the argument
repository that accepts a
PricingRepositoryInterface implementation instance. This instance is used in different functions within the
PricingEngine::Engine class. It looks like this:
The Final Implementation
The final implementation looks like this:
One of the problems we can see here is that if the consumer returns an object that doesn’t respond to the methods and attributes that the gem expects it breaks the gem’s logic. Thankfully, we solve this problem by using Sorbet types.
Implementing Sorbet Types
With Sorbet function signatures we force each function in the Repository implementation to return the type the gem expects which forces the consumer to respect the contract! We also leverage Sorbet interfaces that force each implementation class to implement all the functions defined in the interface. By doing this, we don’t have to manually raise an error if there’s a function that isn’t implemented. The complete code snippet is hosted on Github. For more information about Sorbet at Shopify you can check out our post Adopting Sorbet at Scale.
Testing the Repository
There are different ways to test this pattern. We have to make sure that:
- the gem works in isolation,
- each consumer implements the interface with the right logic,
- the gem works within the consumer.
For testing the gem in isolation we use a mock that simulates an implementation of the repository and uses it across tests. In our specific case, our mock that lives in the gem receives data within the constructor function and does some logic based on that data. In certain scenarios, we might want to have more than one mock. In other scenarios, we may want to return static data. Each consumer needs unit tests that test the behavior of each function within the interface’s implementation.
The last piece that we need to test is that the gem works properly within the consumer. This is where integration tests come into play! The integration tests live in each consumer. In our specific case, we built scripts that are shared across the consumers that help when setting data and expectations.
By using the Repository pattern we made the gem stateless and removed the circular dependency between the gem and the consumers. We defined a contract in the gem that each consumer needed to adapt. Sorbet was very helpful as it forced the consumer to respect the contract by making the code typed.
Ignacio Chiazzo is a Senior Developer. He’s worked on several teams during his 4 years at Shopify. He’s on the Pricing team that’s currently building the tools our merchants need to create profitable pricing and discounting strategies. If you want to connect with Ignacio follow him on Twitter.
If building systems from the ground up to solve real-world problems interests you, our Engineering blog has stories about other challenges we have encountered. Visit our Engineering career page to find out about our open positions. Join our remote team and work (almost) anywhere. Learn about how we’re hiring to design the future together - a future that is digital by default.