Every conference talk about breaking up a Rails app shows the same two slides. First, app/models with product.rb, price.rb, project.rb and task.rb sitting next to each other. Then the same files moved into packages/products/app/models and packages/projects/app/models, each folder with its own package.yml. The second slide looks like progress. The question nobody answers on stage is which of your actual problems disappeared between slide one and slide two.

So I built that app and ran the tooling against it. Ruby 4.0.2, Rails 8.1.3, Packwerk 3, plus a generated mountable engine. Every command output below comes from that app, with the model names changed to a domain that looks more like real work: projects and products.

The folder move by itself does nothing

Moving files under packages/ does not make Rails aware of them. Zeitwerk autoloads what is on the autoload path and nothing else, so the first thing a modular monolith needs is wiring:

# config/application.rb
module Workbench
  class Application < Rails::Application
    config.load_defaults 8.1

    Dir.glob(Rails.root.join("packages/*/app/*")).each do |path|
      config.autoload_paths << path
      config.eager_load_paths << path
    end
  end
end

Two gems exist so you do not have to hand-write this. packs-rails adds autoload paths for a flat packs/ folder and also teaches RSpec and FactoryBot to find specs and factories inside each pack. Packwerk's own README points the other way: it calls packs-rails convenient, then says that for autoloading it recommends Rails::Engines instead. Whichever you pick, what you have at this point is folders and a longer application.rb, with nothing enforced yet.

What Packwerk actually checks

Exactly one thing. The USAGE guide is blunt about it: "Packwerk ships with dependency boundary checking only. Other checking support may be added by extension gems."

A package declares what it is allowed to name:

# packages/projects/package.yml
enforce_dependencies: true
dependencies:
  - "."

That "." is the root package, and it matters more than it looks. ApplicationRecord, ApplicationController and every concern in app/ live there. Leave the root out of dependencies and your first run reports a violation for every model in every pack before it gets anywhere near the interesting ones.

With the root declared and nothing else, a plain cross-package reference gets caught:

# packages/projects/app/models/projects/project.rb
module Projects
  class Project < ApplicationRecord
    def billable_for_product?(product_id)
      product = Products::Product.find_by(id: product_id)
      product.present? && product.pricing_model == "subscription"
    end
  end
end
packages/projects/app/models/projects/project.rb:4:16
Dependency violation: ::Products::Product belongs to 'packages/products', but
'packages/projects' does not specify a dependency on 'packages/products'.

1 offense detected

Worth correcting a common belief here: the public interface story, the app/public folder and enforce_privacy, is no longer part of Packwerk. It was extracted into the separate packwerk-extensions gem, which also carries the visibility, folder privacy and layer checkers. Core Packwerk checks dependencies and nothing else.

The escape hatch is one command long

Existing apps have thousands of these violations, so Packwerk ships a way to record them instead of fixing them:

bin/packwerk update-todo
# packages/projects/package_todo.yml
packages/products:
  "::Products::Product":
    violations:
    - dependency
    files:
    - packages/projects/app/models/projects/project.rb

After that, bin/packwerk check reports "No offenses detected" against code that has not changed at all. This is deliberate, and Packwerk's own guidance says so: "New dependency violations are never hard-blocked." The docs list five situations where running update-todo on a fresh violation is the right call, including a plain "I am in a rush" case that they push back on but do not prevent.

You can close the hatch per package by switching to enforce_dependencies: strict, which makes update-todo refuse:

⚠️ `package_todo.yml` has been updated, but unlisted strict mode violations were not added.

Strict mode is the setting that turns Packwerk from a report into a rule, and it is worth being honest with yourself about which of the two you are actually running. Until it is on, a boundary you can opt out of with one command in a hurry is a convention with tooling attached, not an enforced boundary.

The coupling Packwerk cannot see

This is the limitation that decides whether the whole exercise reduces coupling, and it is documented in the README rather than hidden: "Method calls and objects passed around the application are completely ignored. Packwerk only cares about static constant references."

Here is what that means in practice. Take the violating Project above and rewrite it so it takes the product as an argument:

# packages/projects/app/models/projects/project.rb
module Projects
  class Project < ApplicationRecord
    BILLABLE_MODELS = %w[subscription usage_based].freeze

    def billable_for?(product)
      BILLABLE_MODELS.include?(product.pricing_model) && product.category == "software"
    end
  end
end

Same behaviour. Same knowledge: Project still knows that the thing it is handed has a pricing_model, has a category, and that "software" is a meaningful value. It is still a Products::Product at runtime. The only thing that changed is that no constant from the products package appears in the file.

### direct constant reference
1 offense detected

### same coupling, duck-typed argument
No offenses detected

The author's slide from that talk shows the more disciplined version of the same move, passing an id and going through a getter. The same idea in this domain:

# passing an id instead of the product object
module Projects
  class Project < ApplicationRecord
    def billable_for_product?(product_id)
      product = Products::ProductGetter.find_product(product_id)
      product && billable_for?(product)
    end
  end
end

That version is genuinely better than the duck-typed one, because Products::ProductGetter is a constant, so the dependency stays visible to the checker and has to be declared. What it is not is less coupled. Project still cannot answer the question without a product, and billable_for? still reads whatever a product exposes. What you bought is an honest inventory of which packs reach into which packs, and that inventory is only as complete as your constant references are explicit. If you want the checker to see method arguments too, the README's answer is Sorbet: signatures are ordinary Ruby that names constants, so Packwerk reads them.

What Rails engines add on top

A mountable engine gives you the structure that packs have to fake:

# engines/products/lib/products/engine.rb
module Products
  class Engine < ::Rails::Engine
    isolate_namespace Products
  end
end

Booted inside a host app, isolate_namespace really does isolate several things. Products::Engine.isolated? returns true, Products.table_name_prefix returns "products_", and the engine gets its own routes and route helpers. Generators inside the engine namespace their output. None of that is nothing, and it is more than a packages/ folder gives you for free.

What it does not do is stop anyone from reaching in. I put a class in the engine that no sane public interface would expose, then called it straight from the host app with bin/rails runner:

isolated?: true
table_name_prefix: "products_"
app can reach engine internal constant: 42

The reverse works too: engine code resolves the host app's ApplicationRecord without declaring anything anywhere. Ruby constants are global, Zeitwerk loads every path into one constant space, and isolate_namespace never claimed otherwise. Both sides also share a single ActiveRecord::Base connection to the same database.

Engines and Packwerk solve different halves and neither replaces the other. Engines give you a real load-path boundary and namespaced routing with no configuration. Packwerk gives you the check that tells you when someone crossed a line. An engine without Packwerk is a folder convention, and Packwerk over a flat packs/ folder is a check over a structure Rails does not understand.

Now the list of problems it was supposed to fix

Slow CI and flaky tests

Packwerk has no concept of test selection. bin/packwerk check --packages=a,b narrows the boundary check, not your suite. packs-rails will put specs inside each pack, but a spec in a pack still boots the full Rails application, still connects to the same database and still runs in the same global constant space as everything else. A test that is flaky because of time, ordering, leaked class state or a shared fixture stays flaky after the move, because none of those causes live at a package boundary.

Packwerk also makes CI slightly longer rather than shorter, because it boots Rails before it parses anything. On the 34-file demo app the whole bin/packwerk check run takes about 0.53 seconds wall clock, of which 0.34 is the scan itself and the rest is boot. On a real codebase that boot is not 0.2 seconds, and it is one more of them in the pipeline.

Deployment scalability

Unchanged, by design. Packs and engines are load-path arrangements inside one process. My engine and my packs run in the same Rails process against the same connection. Package boundaries are a source-code-time concern and deployment is a runtime concern, and no amount of package.yml changes what gets deployed.

The one thing a clean dependency graph does buy you here is optionality later. If packages/billing genuinely has no inbound edges, extracting it into a service becomes a scoped project instead of an archaeology project. That is a future discount, not a current improvement.

Less tightly coupled code

Half true, and the half matters. Static constant coupling becomes explicit, directed, and provably acyclic, because bin/packwerk validate refuses a cycle:

Expected the package dependency graph to be acyclic, but it contains the
following circular dependencies:

	- packages/projects → packages/products → packages/projects

Semantic coupling is exactly where it was. The experiment above is the whole argument: one refactor that removed no knowledge whatsoever took the app from one offense to zero.

Finding and assigning owners

Packwerk has a metadata: key, usually shown holding stewards and a Slack channel, and the docs are explicit that "Metadata won't be validated, and can thus be anything". Nothing reads it unless you write the code that reads it. If you want ownership that actually routes review, that is a different gem: code_ownership (2.1.3 at the time of writing) resolves owners from team files, directory .codeowner markers and package metadata, and generates .github/CODEOWNERS from them.

So ownership is available in this ecosystem, but you get it from a second tool, and the hard part was never the file format.

Onboarding

Boundaries tell a newcomer where a constant lives. They do not shrink how much of the app you must hold in your head to change something, because the dependency list is a precise map of what you still have to read. A pack that declares eight dependencies means a change there can involve nine packages instead of one folder. That is better than an unbounded search, and it is not the same as a smaller problem.

What you actually get

Strip the pitch away and four concrete things remain. You get a declared, directed and acyclic dependency graph that CI verifies. You get a package_todo.yml per package, which is a precise file-level list of every place your intended design and your real code disagree. You get a supported location for a public interface, once you add packwerk-extensions and its app/public convention. And you get a much smaller blast radius for the everyday question of where a new class belongs.

What breaks first

Five things bit me during this experiment or are documented to bite, and they arrive in roughly this order.

The root package drowns your first run. Until every pack lists "." in its dependencies, ApplicationRecord and ApplicationController generate a violation per file, and the real findings are buried.

packwerk-extensions is on its own release cadence and not in lockstep with Packwerk. Packwerk 3.3.0 shipped in May 2026; packwerk-extensions 0.3.0 shipped in June 2024. On that pair, adding the gem and enabling only enforce_privacy: true made bin/packwerk check die with undefined method '[]' for nil raised by the layer checker, and it kept dying until I added an unrelated layers: list to packwerk.yml. Privacy enforcement worked correctly once that was in place.

package_todo.yml is a generated file per package, so any two parallel pull requests touching the same pack conflict in it. It also grows quietly, because adding to it is one command and reading it is nobody's job.

Custom Zeitwerk configuration produces false positives. The README is specific: custom ActiveSupport inflections are fine, but configuring Zeitwerk directly with custom inflections or collapsed directories will confuse Packwerk into reporting violations that are not real.

Finally, engines versus a flat packs/ folder is a choice you make once and live with, because moving between them rewrites every path in every package.yml, every package_todo.yml and your CI configuration.

The honest version

Adopt a modular monolith if you want the dependency graph of your application written down and verified, and if you are willing to turn on enforce_dependencies: strict at some point. That is a real and useful thing to own.

Do not adopt it expecting faster CI, deterministic tests, independent deploys or shorter onboarding, because the tooling never claimed those and running it does not produce them. Packwerk's own README opens with the Sandi Metz line about knowing who you are and therefore knowing what you do, and calls that knowledge a dependency that raises the cost of change. It is honest about being a tool that shows you where that knowledge lives, not one that removes it.

If your real reason for looking at packages is that the monolith has become risky to change, the order in which you do things matters more than which gem you pick, and I go through that separately in Strategy to refactor a live monolith without betting the company.

Happy packwerking!