From Monolith to Modules: Scaling Rails with Packwerk the Right Way

Modularizing a Rails App with Packwerk — The Real-World Lessons I Learned

Scaling Rails with Packwerk

The Unexpected Discovery Inside a Rails Monolith

In the very early days of my internship, I started using Ruby on Rails — fast, intuitive, and opinionated in all the best ways. I was hooked.

As I started working with the codebases of larger applications, I noticed a few things:
Rails monoliths grow quickly — and they grow messy.

Models, controllers, services, and helpers are all piled together in a few directories. The app starts out simple, and before you know it and you’re extremely fast and you have well over a hundred files in app/models. After a new developer questions the relationship a given feature has between its dependencies… it’s going to be a guess as to where that feature leads you to within the code.

I held onto what many a developer believe by default:

A microservices architecture is one option, but not a mandatory one!

That sentiment stood — until I joined a client project and witnessed something that completely countered my perspective.

A Codebase That Felt… Different

The client app was mature, high-traffic, and large — the kind of Rails app where change usually feels risky. But this one didn’t.

As I explored it, I noticed:
– Features felt neatly grouped
– Unrelated parts didn’t seem tangled together
– I could easily find what I was looking for
So I started digging — and found the reason behind this clarity:

The team was using Packwerk, a tool built by Shopify to add structure and boundaries inside monolithic Rails apps.

This discovery changed how I think about scaling Rails.

What is Packwerk (And What It Really Solves)

For setup, do visit Packwerk Official Repo

Packwerk does not break your app into microservices. It does not provide new infra. Rather, it helps you to separate your monolith into isolated “packs”, or logical domains with boundaries and rules.

It encourages architectural discipline within the monolith. Each pack becomes a self-contained unit with clear visibility rules, internal vs. external APIs, and controlled dependencies. This makes the codebase easier to understand, change, and scale — especially in large teams.

Solution Packwerk Provides

– What parts of it were public
– What other packs it could depend on
– Which files were private and protected

Essentially, think of it as creating mini-Rails apps within your monolith, but with defined boundaries.

The Problems It Solved — Firsthand

1. The Monolith Felt Really Large

Before: Navigating the codebase meant jumping across 80+ files scattered across unrelated domains — from authentication logic to background jobs to UI handlers — all tightly coupled and mixed together.
After: With Packwerk, related files were grouped into well-defined packs. You could open a single folder and instantly know that everything inside it served one purpose. It made the app feel smaller, cleaner, and more focused.

2. No Privacy = Accidental Coupling

Before: Any part of the app could call any internal class from anywhere. Developers unknowingly created deep dependencies across the system, leading to tangled code and surprise bugs when something changed elsewhere.
After: Turning on enforce_privacy: true in Packwerk made boundaries real. You could only use the public interface of another pack. If you touched something private, Packwerk blocked it — during build time — saving hours of debugging later.

3. Refactors Were Scary

Before: Updating a method or renaming a class was a gamble. You never really knew what depended on what, and testing coverage wasn’t always enough to catch breakages.
After: Each pack explicitly declared what it depended on in package.yml. This meant changes couldn’t introduce silent, hidden dependencies — and if something wasn’t declared, CI would stop the pull request before it ever hit main.

4. CI = First Line of Defense

One of Packwerk’s most powerful features was how it integrated with our CI pipeline.

Instead of discovering boundary violations in production (or worse — during a Friday deploy), CI would immediately fail if:

– A private class was accessed outside of its pack
– A pack did a dependency on something it hadn’t declared
– A team was accidentally introducing architectural drift

Thus feedback loop saved us hours of debugging time — and kept the code base from slipping back into chaos.

The Developer Experience with Packwerk

Here’s a simplified example of how a Rails project might be structured using Packwerk — just to help you visualize what it looks like in practice:

Example Structure

Imagine your app is like a company with different departments (packs). Each department handles its own responsibility and only exposes what others need to know.

Scenario: You’re building an e-commerce app with two main parts:

  • Users who can sign in, have profiles, etc.
  • Orders placed by those users.

Here’s how Packwerk helps:

Folder Structure Breakdown

packs/
├── orders/
│ ├── app/models/orders/order.rb # Internal logic for Order model
│ ├── public/orders/order_service.rb # Shared business logic (e.g., place_order)
│ └── package.yml # Ownership + boundaries
├── users/
│ ├── app/models/users/user.rb # Internal User model
│ ├── public/users/user_profile.rb # Public logic (e.g., fetch_profile)
│ └── package.yml

Packs as Departments:

orders/ is the Orders Department
users/ is the Users Department

This layout isn’t boilerplate — just an example to show how teams might separate concerns using Packwerk.

Each department has:

  • Private Work (hidden/internal logic) → goes in app/
  • Public Services (shared with other teams) → goes in public/
  • Team Info and Rules → defined in package.yml

package.yml for orders/ Pack

enforce_privacy: true           # Can't use private files from other packs
enforce_dependencies: true # Can only depend on what you declare here
dependencies:
- packs/users # Orders is allowed to talk to Users
metadata:
owner: "@orders-team" # Who reviews or maintains this pack

Example Usage

In order_service.rb (public file), if you want to use the user’s profile:

# Allowed: public dependency
UserProfile.fetch(user_id)

But if you try to use user.rb directly:

# Not Allowed: accessing private file of users pack
User.find(user_id)

Packwerk will raise an error.

What this achieves:

– Privacy You can only access files in public/, not someone else’s private internals

– Dependencies You must declare who you’re depending on, so things stay predictable

– Ownership Each team owns their own code. If there’s a bug or PR, you know who to contact

The Mindset Shift It Gave Me

Before this project, I truly believed:

“Monoliths eventually have to be broken into microservices.”

But now, I understand this instead:

“Monoliths don’t scale badly — unstructured monoliths do.”

Packwerk showed me that we can scale Rails apps without fragmenting them.

Why I’d Recommend Packwerk

If you are working on a Rails app that is getting out of control, Packwerk can:

– Help your team establish architectural boundaries
– Prevent developers from introducing hidden dependencies
– Speed up on-boarding and make codebases easier to navigate
– Prevent microservice complexity too early

The best part? It doesn’t change the way you build Rails apps, it just makes them safer and more maintainable!

Conclusion

Packwerk won’t solve every scaling challenge — but it solves a big one that many Rails developers struggle with: how to grow a monolith without losing control.

For me, discovering Packwerk was eye-opening. It changed how I think about Rails architecture, modularity, and team velocity.

If you’re working on a growing Rails monolith, I genuinely believe it can shift your perspective too — just like it did for me.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.