Why “God Objects” Need a Slow Strangle

When a class grows past a few thousand lines, its responsibilities blur. The Shop model in Shopify’s Ruby on Rails monolith is a prime example: at over 3,000 lines, it acts as a God Object that knows and does far too much. Originally, it cleanly represented an online store. Today, it carries numerous global attributes and business processes that belong elsewhere—making it harder to test, reuse, and reason about, while inviting even more complexity to pile on.

Our team, Kernel Architecture Patterns, focuses on enforcing clean and scalable architecture. We had already spent significant effort decomposing the monolith at the component level, establishing clear boundaries between different domains. The next challenge was to apply the same discipline within a component, refining the boundaries of individual objects like Shop.

To find a starting point, we used Flog, a code metric tool that scores code based on assignments, branches, and calls. Flog pointed to a particularly disordered section: store settings. These global attributes were tangled into Shop and referenced across unfamiliar components.

Extracting these settings promised better cohesion and decoupling, but it was a risky operation. We needed to avoid downtime, account for potentially incorrect assumptions about where code should live, and ensure every step could be easily reversed. Replacing the legacy system in one shot was out of the question. This is where Martin Fowler’s Strangler Fig Pattern came in.

The Strangler Fig Pattern: Incremental Extraction

The pattern describes growing a new system slowly over an old one until the old system is “strangled” and removed. Its core strength is that changes are incremental and constantly monitored, so the risk of unexpected breakage stays low. The old system stays in place until the new one proves itself.

Here is the seven-step process we developed to extract store settings from Shop, illustrated with a concrete example from Shopify Capital. In this scenario, a boolean locked_settings attribute on the store indicates that certain functionality is locked while a merchant has an active capital loan.

Step 1: Define the New Public Interface

First, define the public interface for what you are extracting. Depending on the situation, this might mean adding methods to an existing class or creating a new model. At this early stage, you still depend on the existing data source.

Based on the existing Shop methods that reference locked_settings, we defined a new interface in a plain Ruby class, Capital::SettingsToLock, within the Capital component. We continued reading and writing to the Shop object during this phase, but now clients could call the new interface instead. We also supplied tests for the new interface to ensure its behavior.

Step 2: Route Calls to the New Interface

Next, we replaced calls to the legacy Shop attributes with calls to the new system. For instance, an admin controller method that previously went straight to Shop to lock settings was changed to message Capital::SettingsToLock instead. The change is small but significant—clients now depend on the new abstraction, not the bloated host class.

Step 3: Create a Dedicated Data Source

When data is written through the new interface, write it to an appropriate new data source. This can be a new column or an entirely new table. In our case, no Capital-specific table existed for locked settings, and creating a new class signaled that we needed a new table.

We created the capital_shop_settings_locks table, including a reference to the shop and the locked_settings column.

Step 4: Sync Writes to Both Sources

This step involves connecting the new class to the new data source while writing to both old and new systems in tandem. We deliberately kept business logic and persistence separate, so we introduced a record object at the persistence layer—a subclass of ApplicationRecord named Capital::SettingsToLockRecord—to handle interactions with the new table and its validations.

We also added tests for these validations and modified Capital::SettingsToLock’s constructor to accept a repository parameter defaulting to the record class. A private getter uses find_or_initialize_by to fetch the right record for a given shop_id.

ruby # Example of the sync-write pattern (illustrative)

The core task here was updating the lock and unlock methods to write to both the old shops table and the new capital_shop_settings_locks table, wrapping the double writes in transactions to keep everything consistent during this transitional period. Tests stubbed the record’s find_or_initialize_by to verify these dual writes.

With this complete, we successfully maintained data integrity across both sources, marking the end of this step. Subsequent steps—backfilling the new data source, cutting reads over entirely, and finally deleting the old code—can now proceed safely, each one a small, reversible step toward strangling the God Object.

Backfilling the New Table

With the double write in place, the new capital_shop_settings_locks table only contains data written after the switch. The existing locked_settings values stored in the shops table still need to be moved over. That requires a backfill job that iterates over every shop and creates or updates a corresponding Capital::SettingsToLockRecord.

Shopify's open-source iteration API, an extension to Active Job, is well suited for this. It provides safer iteration over large collections compared to a standard Rails job. The API's two key methods — build_enumerator, which defines the collection to iterate, and each_iteration, which specifies the action for each item — keep the backfill logic clean and resumable.

Two details matter in the backfill implementation. First, a pessimistic lock is placed on the Shop object before updating the settings record. This prevents a race condition where a concurrent double write could otherwise cause inconsistency between the old and new tables. Second, a logger records any persistence failure when updating the settings record. Without that logging, diagnosing a partial backfill failure would require tedious manual data comparison.

Tests for the job cover both paths: the happy path confirms records are created or updated for every shop; the unhappy path simulates a failed update and verifies the expected log output is generated.

The backfill task is enqueued from a Rails migration. After it completes, comparing data between the two tables is a prudent sanity check to confirm the sources are in sync.

Switching Readers to the New Source

Once the new table is populated and receiving writes, the reader methods in the business logic class can be pointed at the new source. In the example, Capital::SettingsToLock has a single reader that previously accessed locked_settings directly off the shop. The change is minimal: instead of reading from the legacy shops column, the method now reads through the Capital::SettingsToLockRecord object.

Removing the Strangled Code

The final step is deleting the legacy implementation. At this point, all access to locked_settings flows through the Capital::SettingsToLock interface, which reads from and writes to the new table via the Capital::SettingsToLockRecord model. The old code path is now dead weight.

Within Capital::SettingsToLock, the writes to shops.locked_settings in both lock and unlock are removed, along with the obsolete getter for the shop. Corresponding tests in Capital::SettingsToLockTest that asserted writes to the old column are deleted as well. Finally, the legacy column is dropped from the shops table via a migration.

With that, the extraction is complete: the settings data and its associated business logic now live entirely in the new model, and no trace of the old column remains.

The Pattern in Review

The Strangler Fig Pattern's value comes from its incremental structure. Each step is a small, verifiable change that keeps the application running and tests green, allowing the team to monitor the migration as it progresses. The complete sequence can be summarized as:

  1. Define the interface for the new system.
  2. Replace reads to the old system with reads through the new interface.
  3. Create the new table and a record object for the business logic model to interface with it.
  4. Start writing to the new data source from the new system.
  5. Backfill the new table with data from the old source.
  6. Change the business logic readers to use the new table.
  7. Stop writing to the old source and delete the legacy code.

This approach reduces the risk typically associated with large refactors by never requiring a risky "big bang" cutover. For a first attempt, it's best applied to a small system with solid test coverage, where each step's impact is easy to assess.