Why Rails Apps Need Walls

Shopify’s core codebase is large and growing quickly, and that scale makes architectural boundaries essential. Ruby itself offers little in the way of boundary enforcement — visibility is only available at the class level, and all dependencies load into a global namespace with no distinction between direct and indirect dependencies. Rails provides only a basic layering structure, which is insufficient for a monolith with many large-scale domains.

Without enforced boundaries, developers face real pain: a small change in one area can break unrelated tests elsewhere, and classes balloon into thousands of lines as they absorb responsibilities across domains. The result is spaghetti code, low cohesion, and high coupling.

Cohesion, Coupling, and Boundaries

High cohesion and low coupling make a large codebase feel small. Cohesion measures how well elements within a module or class belong together — code that changes together should live together, ideally grouped around a single task. Coupling measures dependency between modules: independent code should stay independent in where it’s implemented. When a domain’s code depends on many unrelated domains, boundaries have broken down.

Packwerk recognizes two specific kinds of boundaries within a codebase:

  • Dependency boundaries — a class should have a small, intentional list of dependencies on other constants, and should not reach into classes that aren't declared dependencies.
  • Privacy boundaries — external reference should go through a package's public API (its app/public folder) rather than reaching into private constants.

Why Current Solutions Fall Short

Before Packwerk, Shopify evaluated several existing approaches to boundary enforcement in Ruby. None proved sufficient.

private_constant is unreliable

Ruby's built-in private_constant keyword provides visibility semantics at the namespace level, which is desirable. But there's a critical caveat: once a constant is made private, the first reference is not checked for violations. It's also hard to tell whether a constant is being bypassed appropriately, and it addresses only privacy — never dependency boundaries. As a standalone tool, it can't enforce boundaries across large domains.

Gems lack visibility semantics

Gems are a natural way to group related code, and they provide a dependency list via the gemspec. However, gems don't enforce those dependencies as boundaries — all transitive dependencies become available to the main application just like direct ones. Gems provide no mechanism for privacy; every constant is reachable from anywhere. They give layering without any enforcement.

Tests only catch one pattern

Shopify added a test to reject PRs introducing Active Record associations across components. It was useful for starting developer discussions but incomplete: it considered neither dependency direction nor association types, and it only covered Active Record. The test still exists in the codebase for its value in prompting conversations, but it's supplementary at best.

Modulation changes runtime behavior

Modulation is a gem for file-level dependency management, but at the time it was experimental and required replacing Rails' default autoloading. It performs dependency introspection at runtime, adding complexity and risk. Reverting would have meant undoing changes across hundreds of files in a production codebase — too costly for an experiment. File-level granularity, moreover, was too fine for the scale Shopify was solving.

The Problem With Microservices as a Fix

Extracting monolith components into microservices to create boundaries is a common suggestion. But doing so to decouple code solves a design problem with a topology change. Poor APIs stay poorly designed inside services, only now they carry the added complexity of network boundaries, serialization, and reliability. Microservices make sense when a service is isolated and unique enough to justify that tradeoff — but the solution to enforcing boundaries is not to move the code to another machine. Shopify's core remains a modular monolith, and the next step is enforced boundaries within it.

Packwerk's Enforcement Model

Packwerk addresses the two boundaries that matter. It flags a dependency violation when a package references a private constant from a package not declared as a dependency, and a privacy violation when external code references a package's private constants. The contents of app/public are the package's public API and are freely accessible without triggering a violation.

Static Analysis and Validation

Packwerk performs its constant resolution statically, relying on Shopify's open-source ConstantResolver gem. The resolver follows the same conventions as Zeitwerk, Rails' code loader, to map constants to file locations. For instance, Some::Nested::Model resolves to the constant defined at models/some/nested/model.rb, and Packwerk then uses that path to identify the owning package.

Once constants are resolved, Packwerk evaluates each reference against the package configurations. A reference from Package A to Package B passes only if both conditions hold: Package A explicitly declares a dependency on Package B, and the referenced constant is marked public within Package B.

For Packwerk to operate reliably, the application must be in a valid state with correct autoload paths, package definitions, and folder structure. The packwerk validate command exists for continuous integration pipelines to enforce that state. Additionally, Packwerk enforces the Acyclic Dependency Principle: no cycles are permitted in the package dependency graph. Cyclic dependencies create a domino effect; a change in one package forces modifications in every package within the cycle. In a large codebase with many concurrent developers, cycles make integration difficult because every iteration requires coordinated changes across all dependent components. An acyclic graph allows packages to be tested and released independently.

Package Definitions

A package is simply a folder with a package.yml file at its root. That file declares the enforcement rules the package wants to follow. It can also carry metadata such as team ownership and contact information, which proves useful for cross-team collaboration since responsibilities are tracked at a finer granularity than an entire domain.

Enforcement runs through packwerk check, both locally and in CI. Shopify's pipeline includes this check so no new violations reach the main branch.

Dealing With Existing Violations

Legacy Rails applications rarely have clean boundaries, so Packwerk must accommodate pre-existing dependency and privacy violations without letting the list grow. The solution is a deprecated references list generated per package. This list captures existing violations without blocking the developer's current workflow, serving as a backlog for paying down technical debt incrementally.

List of deprecated references for components/online_store

The deprecated references list includes details about each breach. In the example, a privacy violation is visible: specific files reference the ::RetailStore constant defined in a different package. Surfacing these files provides an actionable to-do list.

The intended use is to establish boundaries immediately and then reduce violations over time. The Shipping team at Shopify, however, leveraged the list to extract a domain into a separate service. It can similarly serve teams extracting a package into a gem. Regardless of the long-term goal, the list should shrink, not grow. In cases where a dependency must temporarily go in the wrong direction because no proper pattern exists, adding that reference to the deprecated list is preferable to encoding the dependency incorrectly. It ensures the issue is revisited when the right pattern becomes available.

Runtime Checking and Limitations

Once packages exist and enforcement is active, packwerk check surfaces violations with the violation type, its location, and a suggested next step. This output helps developers recognize boundary-breaking changes before merging.

Static analysis of Ruby comes with inherent trade-offs. Packwerk ignores constants that are not autoloaded, eliminating false positives at the cost of possible false negatives. Catching most references is sufficient to shift code quality in the right direction; this is a deliberate design choice to handle Ruby's dynamic nature.

Usage at Shopify

Packwerk's adoption at Shopify was organic. Teams volunteered for beta testing, and usage spread from there. Today Packwerk runs in six Rails applications at Shopify, including the core monolith. The monolith alone contains 48 packages with boundary enforcement active on 30 of them. All these applications have Packwerk hooked into CI with additional local commands available.

The tool has also started conversations about software architecture inside the company. Developers working on refactoring realized there is no established standard for decoupling code and creating single-direction dependency flows. Shopify is exploring inversion-of-control patterns and ways to formalize dependency inversion in Rails applications.

Getting Started With Packwerk

Packwerk is open source. To install, add the gem to the Rails application and run packwerk init, which generates required configuration files.

The project is maintained by Shopify; bugs can be reported and pull requests are welcome under the contribution guidelines on GitHub. Packwerk was inspired by Stripe's internal Ruby package structure, adapted for the more complex realities of Rails applications. Additional documentation is available in the project's README and the USAGE.md file on GitHub.