Breaking the Circular Dependency in Ruby

Shared Ruby gems often sit between two or more consumer applications. That works well until the gem needs data owned by the consumer and the consumer needs logic owned by the gem. At that point the two have a direct circular dependency: each must talk to the other to do its job. The Repository pattern, combined with Dependency Injection, offers a clean way to break that cycle.

Shopify encountered this exact situation with an internal pricing gem used by two independent Rails applications: Shopify Core, the monolith behind the admin and related services, and the Storefront Renderer, a dedicated server-side rendering engine optimized for fast storefront responses. Both applications share a feature that calculates prices for Products and Variants. A price might be a simple value pulled from a database column, or it might require multiple queries and heavy application logic. That logic lives in the gem, which has a main entry point class, PricingEngine::Engine, exposing functions like calculate_prices_for_variants.

The problem is structural. The consumer stores the data—buyer context such as country, for example—in shapes and data sources tailored to its own needs. The gem holds the calculation logic. The consumers have the data and the gem has the knowledge, which forces a circular dependency where each side must query the other. The gem has no business knowing how the consumer executes queries, especially when the consumer might need to reach external services.

The requirements are clear: calculation logic stays in the gem, the gem remains stateless, data stays in the consumer, and the gem never directly asks the consumer for data.

Repository as the Mediator

The Repository pattern addresses exactly this. As defined by Edward Hieatt and Rob Mee in Patterns of Enterprise Application Architecture, a Repository mediates between domain and data mapping layers, acting like an in-memory domain object collection. It encapsulates the set of objects persisted in a data store and the operations performed over them, yielding a clean separation and a one-way dependency between domain and data mapping layers.

Applied to this Ruby gem, the pattern works like this: the gem defines an interface class that lists every action it needs from the consumer. The consumer implements that interface and passes the implementation into the gem. The gem never reaches into the consumer; it only works through the interface.

The contract is defined by the gem and implemented by the consumer. The implementation accepts and returns objects the gem knows how to handle. This removes the direct circular dependency entirely.

Defining the Contract

Implementation starts with a contract in the gem. Common domain object types live under a Schema module accessible from the consumers. The gem also defines an interface class, PricingRepositoryInterface, that every consumer-side repository implements. Each function in the interface specifies its parameters and return type. One repository may be enough, but functions can be grouped into multiple repositories if the interface becomes large.

Implementing the Repository

Each consumer creates a repository class that inherits from PricingRepositoryInterface. The class implements every method defined in the interface, adapting the gem's contract to the consumer's own data storage and retrieval logic.

Injecting the Dependency

The consumer passes its repository implementation to the gem through Constructor Injection. The constructor of PricingEngine::Engine takes a repository argument that accepts a PricingRepositoryInterface instance. The engine then uses that instance across its various functions.

The remaining risk is that a consumer might return an object that does not respond to the methods and attributes the gem expects, breaking the gem's logic. That is where Sorbet types come in.

Enforcing the Contract with Sorbet

Using Sorbet function signatures forces every function in the repository implementation to return exactly the type the gem expects. Sorbet's abstract interfaces go further, forcing every implementation class to define all functions from the interface. This eliminates the need to manually raise errors for missing methods—the type system covers it. The complete code example is available on GitHub. Shopify's experience adopting Sorbet across its codebase is documented in the Adopting Sorbet at Scale post.

Testing the Pattern

Testing this setup requires attention at three levels:

  • The gem works in isolation.
  • Each consumer implements the interface with correct logic.
  • The gem works inside each consumer.

For isolation testing, the gem uses a mock that simulates a repository implementation. The mock receives data in its constructor and applies logic to it; multiple mocks may be necessary for different scenarios or when returning static data. Consumers need unit tests covering each function in their implementation.

Integration tests live in each consumer and verify that the gem and repository work together in the real application. Shared test scripts across consumers help set up data and expectations consistently.

Result: Thin, Stateless Gem

The Repository pattern made the pricing gem stateless and removed the circular dependency between it and its consumers. The gem defines a clear contract; each consumer adapts to it. Sorbet's type checking enforces the contract at compile time, and the result is code that is both cleaner and more reliable.