The Tool That Redefined Rails Modularity

When Shopify released Packwerk in September 2020, it addressed a real gap in the Rails ecosystem. The Ruby gem, designed to enforce boundaries and modularize Rails applications, quickly found an audience beyond what its creators anticipated. Its popularity, reflected in an ecosystem of companion tools and conference talks, signals that it answered a lingering need for Rails developers grappling with code organization.

At its core, Packwerk is a static analysis tool in the same family as Rubocop and Sorbet. It scans constant references across a codebase to help developers decouple code and organize it into clearly defined packages. Where it differs, and where it has provoked reflection from its creators at Shopify, is in the subtle ways it shapes how developers think about and evolve their code. The tool's influence extends well beyond flagging violations: it changes the trajectory of a codebase — sometimes for better, sometimes for worse.

What Packwerk Actually Does

The inspiration for Packwerk stems from a simple premise articulated by Sandi Metz: "I know who you are and because of that I know what you do." Knowledge of another part of the system is a dependency that elevates the cost of change. To use Packwerk, a developer must first accomplish two things:

  1. Define a set of packages, laid out in nested file directories.
  2. Specify a non-circular set of dependency relationships among those packages.

Running Packwerk's command-line tool then reveals where constants in one package reference constants in another in a way that violates the declared dependency graph. For existing violations, developers can generate a package_todo.yml file, effectively declaring technical bankruptcy while preventing new violations from being introduced.

The intended benefit follows logically: a well-defined dependency graph reduces coupling and tangling across an application. Code becomes modular enough to move between sections, understand and refactor with minimal cross-dependency friction. Circular dependencies, by contrast, complicate those efforts.

The Privacy Check Divergence

Early in Packwerk's life, an additional feature shifted the tool's focus in a direction its creators did not intend. Privacy checks, applied to the same package definitions, statically declared which constants were public API. Constants placed in a dedicated app/public directory could be referenced from anywhere; everything else was treated as private, and references from outside the package were flagged as violations — regardless of dependency relationships.

Philip Müller, Packwerk's original author, called privacy "sugar": easy to adopt and broadly appealing, but not necessarily aligned with long-term health. The appeal is understandable. In a sprawling codebase, defining dependencies accurately is hard. Declaring a constant public or private feels immediate and intuitive, echoing Ruby's own access modifiers.

But the convenience came at a cost. The privacy checks required a non-standard app/public directory, breaking Rails conventions by conflating architectural location with privacy level. Developers frequently created duplicate subdirectories under app/public for controllers and jobs, matching folders that already existed elsewhere in the app. These unofficial public APIs were rarely documented or thoughtfully designed, creating a proliferation of poorly-defined interfaces that were not created for actual external consumption.

Privacy checks effectively turned Packwerk into an API design tool — something it was never meant to be. The original purpose was to enforce a dependency structure, not to manage public and private interfaces. A package referencing another package's public API is still a violation if no explicit dependency exists between them. Yet developers focused on designing their APIs, drawing attention away from the dependency graph problems the tool was originally designed to solve. Because of these issues, privacy checks were removed when Packwerk reached version 3.0.

What Packwerk Cannot See

From Shopify's retrospective, the most significant pain points with Packwerk arise from its blind spots: things it cannot see, cannot infer, and does not tell its users.

The first step — declaring packages and their dependencies — turns out to be the hardest for developers to get right, particularly in large codebases where everything was historically global. Packwerk offers no guidance here. It eagerly generates a todo file for whatever package definitions you provide, whether or not the structure represents a net improvement. The time spent isolating code under a packaging scheme ends up wasted if the underlying architecture shifts and packages are redefined later.

Developers tend to make their package declarations based on semantic inference — grouping code according to naming or domain logic — which often diverges from runtime behavior. Semantic clues alone are frequently poor proxies for actual coupling. A billing settings model that included multiple culpability flags, for instance, gets placed in a "billing" package by name alone. But detecting fraudulent shops is a concern for every request, not just billing-related ones. The correct placement, at the base of the dependency graph, contradicts what the model's name suggests.

This is difficult because it runs counter to human intuition to organize code around its actual execution rather than its labels. Packwerk relies entirely on its user-provided high-level view of the codebase, meaning that if the declared dependency graph is out of alignment with reality, all the work developers put into resolving violations may yield no improvement. Worse, those efforts may introduce unnecessary indirection and additional complexity into code that was already in working order.

Even with a well-aligned dependency graph in place, Packwerk offers no direction for resolving violations. It only exposes constant references and their relationship to the package structure. Developers approaching a dependency violation have to decide for themselves whether the correct fix involves moving code, adjusting dependencies, or restructuring altogether — with none of the answers visible through the tool itself.

Invisible Dependencies and Runtime Blind Spots

Packwerk has limits beyond the conceptual ones. Most static analysis tools cannot resolve constants that are generated dynamically at runtime, and Packwerk is no exception. But its blind spots run deeper due to the assumption that all constants flow from Zeitwerk autoload directories. Any constant loaded through require, autoload or ActiveSupport::Autoload is entirely invisible to Packwerk.

The gap is consequential: a package that passes Packwerk's checks — with no violations left open — may yet crash at runtime with name errors. Well-defined per Packwerk's graph and entirely broken in execution can be true simultaneously.

For teams using full Rails engines as packages, this problem grows further. Packwerk does not parse routes, fixtures, initializers, or any files outside the app directory. Constant-unrelated dependencies pass through undetected — implicit coupling between engines frequently surfaces only at runtime, when costs are highest. These gaps make boundaries on paper less trustworthy than the ones verified at runtime, and they recommend a critical approach to what Packwerk's output actually communicates.

Putting a Zero-Violation Package to the Test

Packwerk's blind spots become glaringly obvious when you actually try to run packaged code in isolation—loading a package along with its dependencies and nothing else. A package with zero violations, whose dependencies also have zero violations, should in theory be usable without any other code loaded. That is the entire point of a dependency graph.

Shopify decided to test this premise. The obvious candidate was "Platform," the monolith's "junk drawer" of low-level utilities that all other packages depend on. Positioned at the base of the dependency graph, it should by definition have no dependencies of its own. Rather than starting with Platform itself, Shopify carved out a new package beneath it called "Platform Essentials," containing only the most essential base classes like ApplicationController and ApplicationRecord, plus the infrastructure code the rest of the monolith relies on. The goal was to make Platform Essentials to the monolith what Active Support is to Rails.

The Cost of True Isolation

The isolation effort succeeded—Platform Essentials ended up with zero violations and zero dependencies—but the process was far from easy. Reaching that goal required heavy reliance on inversion of control to extract package references out of base layer code, introducing indirection that often made the code harder to understand.

Crossing the finish line also surfaced a bug in Packwerk itself: it failed to clean up stale package todos once all violations were resolved. The fact that this bug went virtually unnoticed until then suggested Shopify was likely the first Packwerk user to completely work through an entire package todo file, years after the tool's initial release. That in itself confirmed a suspicion: Packwerk's rate of identifying problems vastly outpaced developers' capacity (or interest) in fixing them.

With violations resolved, the next step was actually booting the monolith with only Platform Essentials loaded. It didn't work. Initializers and environment files, code loaded without Zeitwerk, and other untracked areas surfaced new violations in places never considered. The fix involved moving initializers and application setup into engines so they wouldn't load when booting the base layer alone. Once boot succeeded, a CI step running the package's tests in isolation revealed even more issues that neither Packwerk's static analysis nor boot had caught.

Even for a package with zero dependencies, reaching full isolation took months. The scale of unresolved dependency issues across the monolith was daunting. More tellingly, the amount of work remaining after resolving all dependency violations highlighted Packwerk's limitations and the need for supplementary tooling to fill its coverage gaps.

But the exercise wasn't really about Packwerk—it was about isolation itself, and whether it was even possible in a codebase built on assumptions of global access. On that question, it was a resounding success. Shopify did something never done before in that codebase, with a concrete completion date, and added CI checks to ensure the progress wouldn't be reversed. Packwerk, given the right context, had played a key part in making it happen.

Domains versus Functions

Shopify's monolith is organized into "components," which were created years ago by sorting thousands of files into a couple dozen buckets representing commerce domains: Delivery, Online Store, Merchandising, Checkouts. At the time, this was an effective way to partition work among teams and limit new component creation.

But running Packwerk quickly revealed that domains and their boundaries don't reflect how the code actually functions. Every component generated monstrously large todo files that only grew with each new feature. Developers could resolve some violations, but the fixes often felt unnatural and overly complicated, as if fighting against what the code was trying to do.

One notable exception was the Platform component, which was from the start a purely system-level concern. It never fit the "commerce domain" mold. When the focus shifted from sorting code to actually running it, Platform's purely functional nature became its greatest asset. Its position in the dependency graph was obvious: at the base, with zero dependencies.

This focus on running code has prompted a rethink of how the monolith is organized. Some components are domains; others are designed around the functional role they play. A checkout flow is a function—the code required for a customer to initiate checkout and pay. Shopify's "checkouts" component, however, also contains controllers and backend code for merchants to modify checkout settings. That code belongs to the checkout domain but not to checkout flow functionality.

Running packages in isolation requires them to be defined strictly by function, yet most components are domain-based. Shopify's current solution treats components as top-level organizational tools that group one or more packages, rather than as a single code unit. Teams still own domains, while individual packages serve as the truly modular units. It's a compromise between human-friendly mental models and the runtime need for well-defined dependency graph units.

A Sharp Knife

Modularizing a large legacy codebase invites idealistic visions of how code should behave. Packwerk enables that tendency: you define the desired end state—the packages, the dependency graph—and work down the todo file until you arrive. The problem is that you can't know in advance whether that path leads to concrete results. Code exerts a powerful pull toward function, and it's often easier to reshape your mental models around what the codebase actually does than to force the code to fit your models.

Shopify learned this the hard way. The initial vision was utopian: modular units representing commerce domains with cleanly defined dependencies. A tool was built to chart the course. The work seemed clear. Then the actual work began, and things looked far less promising. They made it through the todos for a single package—likely the first to do so—only to find the code still broken and unusable in isolation. The imagined utopia didn't exist, and the tool was leading them astray.

The turnaround came with the realization that running code is always the best indicator of real progress. Packwerk measures one aspect of code quality, but it's just one tool among many. Shopify achieved a modest but real victory by staying pragmatic and embracing an approach outside the original plan.

Packwerk, like many Rails ecosystem tools, is a sharp knife that must be wielded with care. Be intentional about how you use it and how you fix the violations it raises. Ask whether a violation is a developer-level error or a dependency-graph-level problem. If it's the latter, consider adjusting the package layout to better reflect the code's actual dependencies.

Shopify has discussed removing Packwerk entirely, given its costs and blind spots. The technical debt from privacy checking remains far from paid off. Nevertheless, Packwerk has held the line against new dependencies at the base layer, and its violation lists remain an effective way to distribute work toward a concrete isolation goal. The lessons learned have informed a broader modularization strategy oriented toward running code and executable results, rather than philosophical ideals. Packwerk is no longer as central as it once was, but it still has a role to play at Shopify—and likely will for years to come.