Every Rails developer hits this wall eventually. The once-shiny monolith you were so proud of? Now it’s tangled in technical debt, sluggish CI times, and a constant fear that the next change will bring everything crashing down. The app that once felt elegant now feels like a ticking time bomb.
At Rails World, Eileen Uchitelle—Rails core team member and a key contributor to the framework’s evolution—didn’t sugarcoat the truth: The problem isn’t your architecture—it’s your team’s culture.
“The myth of the modular monolith is that architecture cannot fix human and cultural problems, but fixing human and cultural problems can improve our architectural, operational, and organizational challenges,” she said.
Her talk hit hard because it exposed an uncomfortable reality: Our issues aren’t rooted in tech—they’re rooted in us. Microservices? Modularization? They won’t save you. The real fix lies in how we work as teams, how we communicate, and how we invest in people, not just architecture.
If this makes you squirm, you’re not alone. Eileen didn’t offer us an easy out—she challenged us to dig deeper. And at Test Double, we couldn’t agree more. We’ve always believed that the tools and frameworks we use are only as good as the teams behind them.
Her talk was a rallying cry for Rails devs to stop blaming the framework, stop hunting for architectural band-aids, and start investing in what truly matters: our teams, our processes, and ourselves.
“Eileen’s keynote is just amazing. Want to scream ‘amen’ after almost every sentence,” Test Double staff software consultant Jason Karns wrote in Slack.
At Test Double, we live by the mantra that software problems are human problems. No architectural tweaks or shiny tools can fix what is fundamentally a people problem. Our code often reflects the dysfunctions of poor communication, defensive code ownership, or rigid hierarchies.
Here are the 4 biggest takeaways from Eileen’s keynote — and how our consultants reacted:
1. You can’t solve human problems with modularity.
Eileen’s boldest point? No architectural solution can fix a people problem. Or, as she put it: “You can’t solve human problems with modularity.”
Test Double staff software consultant Kevin Baribeau echoed this timeless truth: “No matter what the problem is, it’s always a people problem,” citing Gerry Weinberg’s famous quote.
Kevin emphasized that while technology can help us see where the issues are, only human collaboration can fix them. This aligns with our approach at Test Double—great code starts with great teamwork.
2. Internal code quality pays off in speed and safety.
Eileen called on teams to carve out time for reducing technical debt and improving code quality:
“When the priority is shipping over quality and doing things the right way, technical debt has a way of silently growing until it’s overwhelming,” she said. “Before you know it, you’re years behind on your Rails and Ruby versions, there are monkey patches everywhere, and seemingly simple changes take weeks to ship.”
That left a crucial question for leadership: Why should they care?
Test Double staff software consultant Jason Grosz provided a straightforward answer: “Internal code quality allows developers to ship new features faster, with fewer bugs and side effects.”
Investing in quality doesn’t just lead to cleaner code; it empowers your entire product team to be faster and more innovative, ultimately driving meaningful business outcomes.
3. Defensive code ownership stifles progress.
Eileen issued a stark warning about the dangers of rigid ownership structures, which lead to silos and a pervasive culture of blame.
“When teams prioritize protecting their code over collaboration, they hinder progress,” she cautioned.
Test Double senior software consultant Daniel Huss identified this as a major anti-pattern, noting: “Defensive code ownership and blame are unhealthy.”
The more challenging aspect? Resisting the urge to apply technical solutions to fundamentally human problems.
Daniel pointed out that teams often become so overwhelmed by technical debt that they freeze, unable to move forward because “what’s one more error on the list?” This cycle can only be broken when leadership actively supports efforts to reduce technical debt and foster a collaborative environment.
4. Separation by actionable domains, not entities, unlocks innovation.
Eileen challenged the conventional wisdom around rigid architectural patterns in large-scale monoliths.
“Architecture isn’t about following rules—it’s about creating space for teams to work faster and smarter,” she said.
Test Double staff software consultant Jason Allen agreed, stressing that “separation by actionable domains (not entities)” is key. Tools like Packwerk or domain-driven design let developers introduce necessary separation while maintaining flexibility.
“Done well, modularizing an enterprise application presents a productive and elegant architecture where teams can build, iterate fast, and address defects more quickly,” Jason said.
This ties directly into Jason Grosz’s earlier point about the need for leadership to buy into reducing technical debt. If leadership doesn’t recognize the long-term value of addressing these issues, teams can quickly become paralyzed by the weight of it all.
Conclusion: The myth of the silver bullet
Eileen’s keynote was a refreshing reminder that the problems we face as engineers aren’t going to be solved by technology alone. No new tool, framework, or architecture will magically fix an engineering culture that doesn’t value collaboration, quality, or continuous improvement.
If we want to create scalable, maintainable applications that developers love to work on, we have to invest in our people, our teams, and the culture we build together.
The real work starts with us.
Watch Eileen Uchitelle’s keynote: The Myth of the Modular Monolith
Read Eileen Uchitelle’s keynote transcript
... I’m Eileen Uchitelle, and I’m honored to be your day two keynote. It’s really exciting to be here, and I’m so happy you all could make it to Rails World. Thank you to the Rails Foundation, Amanda, and everyone who worked hard to put this conference on. It’s really lovely to see all of you here. I’m a senior staff software engineer at Shopify on the Ruby and Rails infrastructure team. Toronto is one of our hubs, and we love Rails. Please come by our booth to chat or learn more about the roles we’re currently hiring for.
I’ve been a member of the Rails core team since 2017. The Rails core team is the driving force behind the framework. We make decisions about the direction and evolution of Rails, as well as collaborate with contributors and the community. Being on the core team has been the highlight of my career, enabling me to have a deep and lasting effect on the framework. I’ve been building Rails applications for about 14 years now, and throughout my career, I’ve seen a lot of different types of Rails applications. I’ve worked at companies that had fewer than 10 employees and others with over 11,000. I’ve worked on Rails applications that were brand new, and others that have been around since the dawn of the framework. I’ve seen applications built by developers just learning Rails, and I’ve seen ones written by DHH himself.
Throughout my career, I’ve spent extensive time working on two of the earliest Rails applications in existence: Basecamp Classic and Shopify. While Basecamp Classic is still running in production, it only gets security updates and is still on Rails 3. This makes Shopify’s core monolith the oldest continuously developed production application on the planet. It was built on a version of Rails that wasn’t yet released to the public and now runs on Rails main. While the application has changed a lot in the last 20 years, it’s still fun to look back and see Toby’s first commit and how excited he was to be using Rails.
Between 37signals and Shopify, I worked at GitHub for five years, spending the first two and a half upgrading the main monolith from Rails 3.2 to running off Rails Edge. I think I’m the only person who has worked at all three of these companies and seen these three early Rails applications.
One thing that stood out over the years is that eventually, Rails applications get to a state where the framework stops bringing developers joy. As organizations grow, applications tend to become a “ball of mud.” The code lacks organization and structure, onboarding becomes difficult and painful, CI is slow and flaky, and even small changes hit endless amounts of friction. At this point, many companies ask themselves, “What now?” They feel that Rails is no longer meeting their needs from an architectural perspective. Often, they start looking into microservices to get the experience of building a greenfield application and move away from the monolith. There was a point at GitHub where all anyone could talk about was microservices saving us from ourselves.
About six years ago, Shopify began exploring modularizing our core monolith. It seemed like the best of both worlds: the hope was that we could feel like we were working on a smaller application without the network latency and organizational politics of microservices. It could solve all our engineering problems, and we’d still get to keep writing Rails. What could be better than that, right? Before I left in 2012, GitHub started modularizing their monolith as well, realizing that turning the entire monolith into microservices wasn’t a tenable goal. Gusto, Zendesk, Doximity, and other companies with large monoliths have also adopted this architecture. And while I hear a lot of voices pushing for this new pattern in Rails applications, it’s proven to not be the silver bullet we’d hoped for. After all these years, if we look back at the problems we were trying to solve, they are still ever-present, and new challenges have arisen from our efforts.
The myth of the modular monolith is that it promises a greener pasture—better structure, less coupling—but what we get instead is a new set of challenges and unmet goals. On the surface, our problems appear to be technical, but if we look deeper, we’ll see that they are actually human and cultural challenges, and you can’t solve human and cultural problems by changing your architecture.
Today, we’ll examine the difficulties companies face as their applications scale. We’ll explore why modular monoliths seem like an ideal solution and uncover the underlying causes of the issues we’re trying to address. We’ll discuss how to tackle human problems while avoiding the pitfalls of chasing false promises and silver bullets.
First, let’s talk about the very real pain points that led companies like Shopify, GitHub, Gusto, and others to modularize their Rails applications. Many companies that are expanding, hiring, and growing will face these same challenges over time. I’ve categorized our common problems into three types: architectural, operational, and organizational.
Architectural issues refer to challenges related to the overall design and structure of a software system. They affect the maintainability and scalability of an application. Architectural problems creep in over time as more and more features are added, and refactoring isn’t prioritized. A common architectural problem in growing applications is the lack of organization and structure. Rails is an MVC framework, so all models go in app/models, controllers in app/controllers, and views in app/views, and so on. As the number of models, controllers, and views increases, Rails’ default directory structure can become unwieldy if you’re not careful. The lack of organization and structure means developers rarely consider where new code should go or when existing code should be refactored to live elsewhere. Everything just goes into the default folders, making it difficult to discern what concepts belong together. This is often what developers mean when they talk about the “ball of mud,” as if it’s Rails’ fault and not our own for not following proper design principles.
In addition to the lack of organization and structure, another issue that growing applications face is tight coupling and lack of boundaries. It’s easy to see how this happens in a Rails application. Ruby is a language where code is globally accessible, so without good judgment, code can become tightly coupled. Changes that seem simple can unintentionally impact other parts of the application in ways that are hard to predict and control. When this happens, you either have to fix all the callers or refactor the codebase to separate functionality. These kinds of side effects, caused by tight coupling and lack of boundaries, harm developer productivity.
In addition to architectural problems, applications face operational issues. These refer to the practical aspects of running and maintaining applications and affect the overall happiness of your engineering organization. An example of an operational problem is flaky tests and slow CI. While any size application can have flaky tests, the larger your monolith is, the more likely these issues are to occur, and the harder it becomes to narrow down where they started. Flaky tests block deploys or make CI runs take longer due to rerunning failed tests. A flaky build adds a lot of friction and frustration to development.
Often, engineers working on large monoliths will complain that their CI suite is taking too long. While the number of tests certainly can contribute to longer CI runs, it’s often not the only cause. CI may be slower due to a test that creates too many records, queries that are slow, or network calls that were unintentionally added. There are many things that contribute to a slow test suite, but they become more problematic as the monolith grows faster than these problems are addressed.
In addition to slow CI, when a monolith gets to a certain size, it’s common to hear complaints that it’s difficult to scale deployments effectively. The larger the application, the longer it will take to check out the code and restart the servers. As an application gets more popular, you need more infrastructure to handle customer traffic. Because Rails is a monolith, it’s not possible to deploy more servers for one resource-intensive part of the codebase.
Lastly, organizational issues occur at the company leadership and structure level. They’re related to how work is managed, how quickly problems get solved, and how well an engineering organization is functioning. Organizational problems can happen regardless of the size of your monolith or company, but they are exacerbated by growth and scaling needs. An example of an organizational problem is difficulty assigning and finding code owners in a large application. At small companies, especially if you’ve got only five engineers, everybody is responsible for the entire application. However, that doesn’t scale as your company gets bigger and you have thousands of engineers on an on-call rotation for code they didn’t write and don’t understand. When an application reaches a certain size, you need to split up responsibility so that bugs get fixed and you know who to page during an incident.
Another organizational issue is the length of time onboarding new hires takes. The argument is that it’s hard for new hires to get started shipping code because they can’t find their way around a monolith. If that monolith was modular, they could in theory just focus on the code that belongs to their team. Coming into a giant monolith, especially if you haven’t written Rails before, can be quite daunting. It makes sense that a smaller codebase would feel easier to reason about for new hires.
Lastly, one argument I often hear for why large monoliths are problematic is that people say, “I can’t hold the whole application in my head.” When I hear this, the only thing I can think is, “Why are you even trying to do that?” What I can hold in my head is different from what you can hold in yours, and what DHH can hold in his head is different still. You can thank Aaron for this one and Matthew for the little DHH head.
I don’t agree that this is a real problem, nor do I think it’s a useful metric for whether an application is a ball of mud. Realistically, as our companies and applications grow, our monoliths will grow over time. It’s more important that we focus on having well-designed, properly structured applications that follow Rails’ conventions than it is to pick an arbitrary number of lines of code that we can hold full context on. Having monoliths small enough to hold in your head isn’t a tenable or reasonable goal at scale.
The other problems we’ve discussed, though, are very real. I’ve experienced them at multiple companies, and I’ve heard these problems from others that I’ve talked to. In order to solve this set of problems, companies often reach for microservices and try to carve up their monolith. Instead, Shopify, Gusto, GitHub, Doximity, Zendesk, and others are exploring the modular monolith. A modular monolith is a monolith that is organized into modules inside the codebase by grouping domains into logical directories.
The promise of a modular monolith is that, in theory, it provides the best of both worlds: the isolation and boundaries of microservices with the ease of deployment and development of a monolith. There are a lot of reasons to choose a modular monolith over microservices. With a modular monolith, you get to use the same language. Often, if an organization is moving towards microservices, you’re all now writing Go when Ruby is likely what you were hired to write. This is not a knock on Go—it has its place—but as a Rubyist, I want to write Ruby. So a modular monolith benefits me and other Rubyists in that we get to keep writing the language we love.
In addition to using the same language, modular monoliths mean you don’t have to build out new infrastructure—deploys and CI just work the same way as they did before, which means the overhead for migrating to a modular monolith is a lot lower and quite minimal, at least to start. Deployments and testing for microservices can be more complex if they’re interdependent, whereas a modular monolith is deployed and tested as a single unit.
Another advantage of a modular monolith is that it provides a path toward package isolation. Isolation means creating boundaries between components and attempting to remove dependencies and coupling. In order to define boundaries between your modules, you need to use a tool like Packwerk, and then do the work to remove dependency violations. If you want to modularize your Rails application and your goal is to be able to run separate CI jobs for your package or deploy your packages to separate servers, isolation is going to be required. True isolation is incredibly difficult to achieve, and later we’ll take a closer look at why this might not be a path you want to pursue.
Currently, I’m not aware of any applications that are deploying fully isolated modules of their monoliths to production. At Shopify, we have a single isolated component that can run CI in isolation, but it doesn’t contain any product functionality. It’s essentially our active support of our application and therefore it’s not useful to deploy to its own infrastructure.
Most of the companies using modular monoliths have implemented them the same way. Generally speaking, in the Rails world, a modular monolith uses Rails engines to organize code and Packwerk to define and enforce boundaries between their packages. Rails engines are native to the framework, and Packwerk is a gem written by Shopify.
To understand how this works, let’s say we have an application called Fur and Foliage that sells both plants and pets. In a standard Rails application, the directory structure might look like this: If you have an application with four concepts—dog, cat, tree, and flower—you have corresponding models in the app/models directory, controllers in app/controllers, and so on. If we were to modularize our app with Rails engines, our application might be organized like this: Here we have grouped dog and cat into a Pets engine, while tree and flower are grouped into a Plants engine. We call these packages.
I've purposely oversimplified an example of how modular monoliths work. At Shopify, we have top-level components and many nested packages inside, and other applications are doing that too. But I didn’t want to spend all my time making a fake application about plants and pets for you. There are many blog posts and talks available online that dive deeper into how to figure out where stuff goes and how to modularize your Rails application. This talk is meant to be a higher-level view of the problems we’re trying to solve.
A monolith that’s only modularized with Rails engines doesn’t inherently reduce dependency or create isolation because all the code is still accessible between packages. We can use Packwerk to identify and enforce isolation between plants and pets. As mentioned earlier, Packwerk was written at Shopify, and it’s what most applications are using to enforce boundaries. This talk is not an endorsement or criticism of Packwerk; I’m using it for examples because it’s the only tool I’m aware of that actually does this.
Packwerk allows you to define dependencies between packages and enforce boundaries by not allowing undeclared dependencies. With Packwerk, dependencies are defined in a YAML file. Every package has its own package.yml that contains the metadata for a package and the packages it depends on. For example, let’s say plants depends on pets. This is considered an allowed dependency. This means it’s okay for the plants package to rely on a constant in the pets package. There’s also an enforce_dependencies setting, which prevents adding any new dependencies when it’s set to strict.
In addition to the package.yml file, there is a package_todo.yml file, which declares dependency violations that you want to work on removing. If you want your package to be isolated from all other code in the monolith, you would need to fix all the todos and remove the allowed dependencies. Working through all these todos can be quite difficult in a large application. At Shopify, we have over 40 top-level components, most of which contain nested packages. In our core application, we have over 90 package_todo files, and there’s no concerted effort to burn those down. We’re not even tracking whether we’re actively reducing dependencies because it’s not a useful metric for us. We’ve found that enforcing strict dependencies causes a lot of friction for developers. Packwerk is a useful tool for knowing about dependencies between packages, but it can’t tell you how to write your code with less coupling or how to refactor existing violations to create boundaries.
Rather than think of Packwerk as a to-do list, think of it more as data to help you understand your dependency graph and where changes may be needed. Since modularization and isolation tools can’t tell you how to write better code, new problems can crop up in applications. It’s not Packwerk’s fault by any means, but attempting to fully isolate and remove dependencies in a large-scale monolith is very difficult and can result in undesirable design patterns.
One problem I’ve seen in large monoliths trying to prevent new or reduce existing violations is primitive obsession. An example of primitive obsession is passing IDs around rather than active record objects in order to avoid calling constants between packages. Often, developers will load the records from the database, get the IDs, pass those to another package, and then load the records again using those IDs, because Packwerk doesn’t see that as a violation. To remove this dependency, we need to avoid calling cat directly from the flower model. We can do that by making an explicit public interface called something like CatGetter and passing the ID to that.
Primitive obsession makes the code more difficult to follow and can lead to problems with the database due to unnecessarily complex queries, less efficient queries, and data duplication. If one package already loaded cat just to get the ID to pass it to flower, it means the database is queried for the same data twice. While using primitives might circumvent a dependency violation and reduce coupling, the downside is database performance issues that affect your customers and patterns that don’t utilize the efficiency of ActiveRecord. If you prevent using abstractions that engineers are used to, like ActiveRecord for example, you end up with far worse patterns and issues in your application design and structure.
One of the benefits of a modular monolith is that there’s no network latency between packages like there would be with microservices. However, primitive obsession leads to performance issues that aren’t exactly better. We have to be careful that when we put up guardrails, we don’t end up encouraging worse anti-patterns.
In addition to primitive obsession, a challenge I’ve observed is ownership obsession. This refers to the mindset that code within a package is solely the domain of the owning team, leading to resistance against input or oversight from others. While establishing clear boundaries between packages can promote modularity, it can also create silos that result in an “us vs. them” mentality when the codebase should be viewed as a shared responsibility. Ownership obsession results in a selfish mindset—teams will refuse to fix code in a package they don’t own because they don’t see it as their problem. Often, the right change is the harder change, but they’ll avoid touching someone else’s code at all costs, often to the detriment of code quality and simplicity.
Drawing strict boundaries within an application can have a negative effect on collaboration. The idea of a package functioning like a smaller Rails application is appealing in theory. However, when this leads teams to prevent others from accessing their code or to defend poor design choices, it becomes a negative consequence of modularity and undermines collaboration and overall engineering culture.
Another problem that has crept up is developers being obsessed with putting everything in a new package. Every concept goes into its own package. Teams often want to create yet another package because they feel like it doesn’t fit into the existing ones. If you’re not careful about preventing everything from becoming a new package, you’ll end up with a codebase modeled after your org chart. It’s important to constantly scrutinize whether a new package is really necessary. As humans, we love to categorize things, and we want everything to fit neatly into little boxes, but going as far as to make everything its own category leads to a fractured codebase where related functionality isn’t grouped together. It’s important to think critically about what concepts you turn into a package. You don’t want to end up feeling like your monolith is a bunch of microservices.
In a modularized monolith that enforces boundaries, code duplication often becomes a big problem. No one wants to violate or create new dependencies, so it’s common to see code copied from one package to another. When this happens, it becomes more difficult to maintain duplicated code. If one version changes and the other doesn’t, now you have bugs or make upgrading Rails more difficult. The codebase becomes bloated over time, and if you’re concerned with application design and structure, duplicating code is a massive design smell. Copying code just to avoid a dependency violation should be treated as an opportunity to rethink what code is being used and why. If it’s truly shared, it should be moved to a package that’s meant to have shared code or maybe moved to a gem, but definitely don’t copy it to another package. That is almost never the correct answer.
Another challenge that happens when trying to modularize and isolate your Rails application is circular dependencies. Untangling circular dependencies in a large-scale Rails application is incredibly daunting, and it’s also difficult to prevent new ones from being added. If the pets package depends on plants and plants depends on pets, it becomes much more difficult to isolate one of those packages from the other. To avoid the circular dependency, many developers will use primitives, REST APIs, or GraphQL calls, introducing performance regressions along the way. To correctly isolate these packages, often major refactoring needs to be done to pull out shared code and untangle the mess.
Having explored these problems caused by modularization and isolation, let’s revisit the original problem set to see how our architecture falls short in addressing them. The problems we talked about earlier are ones that many companies cite when reaching for modularization. Yet, if we look at the state of the applications using this architecture today, we’ll see that none of the problems we set out to solve can be fixed simply by changing our architecture. This is because they’re human and cultural problems, not technical ones.
At the root, no amount of moving code around into different directories or migrating to a different architecture can fix these problems. Architectural problems are human problems because our tools can’t tell us how to refactor our code to have better organization and structure; they can only tell us there’s potentially a problem. So, can modularization and isolation automatically improve structure and organization? No. When an application is modularized, code no longer lives in the top-level directory, but that doesn’t mean the packages themselves are well-organized or properly structured. We still need to put a lot of effort into figuring out where the code should live and preventing new packages from becoming disorganized. Humans are pretty good at categorizing things, but we’re not always great at choosing the right category. Simply looking at the name of something doesn’t tell us who uses it or where it should live. While modularization and isolation are ways to improve organization and structure, they still require an understanding of how to design software, which is something we don’t often teach.
Can a modular monolith make our code automatically less tightly coupled? No. While modularization and isolation can help you identify dependencies, they don’t actually reduce coupling or introduce boundaries unless you refactor the existing code. Adding dependency violations to the to-do list and setting up allowed dependencies doesn’t fix the design of your code; it just tells you where there’s possibly an undesirable design. You still have to understand how to rewrite and redesign the existing code while not deviating too far from the Rails way. This is a human problem because it requires educating teams on how to design software for Rails, while avoiding implementing worse anti-patterns like primitive obsession.
Operational problems are human problems because they need to be fixed at the source. They are caused by ignoring technical debt rather than by the architecture being used. So, will modularizing a monolith speed up CI and make tests less flaky? No. There is nothing about modularization and isolation that inherently improves CI testing. Flaky tests aren’t necessarily caused by having a monolith; they are probably caused by network calls, creating too many records, race conditions, resource contention, or leaked state between tests. Because flaky tests have many causes that go beyond architecture, modularizing and isolating Rails applications won’t automatically fix your flaky test suite.
The same goes for speeding up CI. If the reason it’s slow is because you have slow queries or a really large test suite, it’s not going to be made faster by moving your code into smaller directories.
Will modularization allow us to deploy in isolation? Not yet. The existing modularized monoliths in the Rails ecosystem are still deployed as a single unit, as if they were a traditional Rails monolith. As far as I’m aware, no one has isolated a part of their monolith to be deployed separately while remaining in the same codebase. This sounds like an architecture problem, but it’s a human problem because untangling dependencies in order to scale deployments in isolation requires redesigning major parts of an application.
Organizational problems are human problems because they’re deeply rooted in our organization’s leadership and are directly related to what we define as company culture. So, when you modularize your monolith, can finding and assigning code owners be easier? Somewhat, but not really. It is theoretically easier to assign a team to an entire folder instead of individual files. However, just because you know who owns something doesn’t mean you can actually get them to do the work. If exceptions and deprecations aren’t prioritized or incentivized, the owning team will almost always choose to ship features over maintenance work. Additionally, reorgs, team renaming, or pivoting parts of your product can result in sections of the codebase being essentially unowned. As your organization grows, it’s important to be able to define and have ownership, but changing your architecture doesn’t fix that. All it does is contain the code that needs an owner. Ownership is a culture problem, not an architecture one.
In a modular monolith, can onboarding new hires be easier? This is really hard to measure, but I’d argue no. A modularized monolith is still a Rails monolith that runs all the CI tests in one build and is still deployed as a single unit. A change in one package can still affect another because many packages are tightly coupled. So, I don’t think it’s accurate or fair to say that modularization allows new hires to only consider the domains they own. In addition, modularizing a monolith deviates from Rails conventions, and therefore onboarding someone to your application requires teaching them how to use these tools and how they change how code is written for Rails. The tools we use to maintain structure and style create friction in development and may not actually put new hires in a position to ship faster. Setting boundaries in an application doesn’t teach anyone how to write idiomatic Rails code; it just tells them when they have a violation.
Looking back at the problems we set out to solve that are still present and the new problems we’ve created, you might think that I’m against modularization. But where code lives is not my biggest concern. What concerns me is that isolation is actually very hard, especially in an application that is designed to be global. It’s also hard because these monoliths are 15 to 20 years old, made of millions of lines of code, and worked on by thousands of engineers of varying experience. The reason these problems aren’t solved isn’t because we didn’t try hard enough or because modularization is bad or because we don’t have the right tools. The reason we haven’t solved architectural, organizational, and operational issues is because you cannot solve human problems with modularity.
The problems we’re trying to solve are cultural and indicative of dysfunctional engineering organizations. We would have these problems if we stayed in a monolith or migrated to microservices. No amount of architecture can save you from an organizational structure that doesn’t prioritize code quality, fixing technical debt, and allowing engineers to pivot when a path clearly isn’t working. Organizations promote and incentivize silver bullets instead of rewarding maintenance and foundational work. In order to address the problems we looked at today, we need to understand their causes.
How does an application get to the point where it feels like a "ball of mud"? If you’ve worked at a startup or on a greenfield application, you know it doesn’t start out this way. If Rails created a ball of mud from day one, none of us would be here using Rails. The truth is that an application grows into this state slowly over time, commit by commit, until it feels like development and productivity have come to a screeching halt. There’s no single cause of the problems. We often fail to see them until it’s too late, when there’s no time or ability to fix the issues properly. We blame our tools, we blame Rails, and we blame each other.
Pressure to ship is one of the many reasons applications turn into a ball of mud. It’s much easier to keep an application loosely coupled with good structural organization when there are just a few developers adding features and no rush to ship. But then you get funding, and your stakeholders want to see a return on their investment, or you go viral, and your customers are mad because you can’t handle the amount of traffic. The only way to keep going is to ship, ship, ship. Any maintenance work or technical debt is ignored because fixing that isn’t a priority—it’s not even on leadership’s radar.
Over time, code becomes tightly coupled because bolting onto existing functionality is easier than pausing to do the work to properly refactor what’s already too entangled. As the pressure to ship mounts, technical debt grows. This is incurred not only by ignoring maintenance tasks like Rails upgrades and gem updates but also by building changes into existing functionality. When the priority is shipping over quality and doing things the right way, technical debt has a way of silently growing until it’s overwhelming. Before you know it, you’re years behind on your Rails and Ruby versions, there are monkey patches everywhere, and seemingly simple changes take weeks to ship. Fixing exceptions feels near impossible now that there are hundreds of thousands a day, and with all that noise, what’s one more?
No one wants to take responsibility for maintenance tasks when the only way to get promoted is to ship features. The pressure to ship and the mounting technical debt increase the pressure to hire. You need more developers to ship more, and the ones you have aren’t working fast enough because the company is prioritizing feature growth over code quality. New hires never get onboarded properly. No one teaches them Rails or how an application should be designed. They’re thrown into the deep end with no support. They feel overwhelmed and like they aren’t productive, so they end up blaming the framework. New hires complain that Ruby is slow and bad, and we should rewrite everything in some new, faster language like Go. All they see is technical debt, no structure, tight coupling, and slow CI.
Shipping too fast and increased hiring are both symptoms of a larger problem: growing organizations have growing problems, and those problems are caused by misaligned incentives. As organizations grow, they need to add more layers of leadership to make sure everyone’s doing their job properly. OKRs and KPIs become the metric for whether things are going well, and the obvious culture problems that led us to a state where our application feels fragile and fractured continue to be ignored.
In order to meet their OKRs and KPIs, managers and VPs need to know who to assign work to and who’s accountable for outages. The thinking is: if we just knew who was in charge of this code, we can measure who is and isn’t doing their job. While you do need to know who owns code in a large application, it’s often taken too far. Rather than working together and collaborating as an organization, misaligned incentives breed a blame-based culture. This results in siloed teams who want to protect their code from the rest of the organization at all costs.
The desire to modularize and isolate partially comes from wanting to feel like you don’t have to think about the rest of the codebase. However, it quickly turns into a "this code is mine to protect" mentality. I watched this happen at GitHub while I was there. As the application and organization grew, it felt like it became more important to figure out who to blame than to work together on fixing technical debt. Blame-based culture leads to teams wanting to protect their code and database queries from the rest of the organization so they can prove they weren’t the cause of a site outage.
Within the org, keeping teams away from your code becomes more important than collaboration because collaboration means it’s not clear who to blame. This blame-based culture results in the loss of teamwork and empathy. The problems we’ve looked at today aren’t caused by organizational structure and won’t be solved by it because they aren’t problems caused by technology or a lack of technology—they’re human problems caused by culture within an organization. And there’s no silver bullet technology that will fix that.
It takes good leaders and a concerted effort to address the underlying challenges that allow applications to turn into a ball of mud over time. But not all hope is lost. Just because we can’t solve these problems with architecture changes doesn’t mean they’re not fixable or avoidable. It’s not inevitable that your application turns into a ball of mud and your only recourse is to spend years moving files into different folders.
In order to address the human problems we’re facing, we need to improve our developer education. We can’t keep hiring developers and failing to train them on how to write Rails the Rails way. Engineering onboarding at most companies is a week. That’s not enough time for a new hire who isn’t proficient in Rails to really learn Rails. If we don’t train new hires on why we use Rails, how to write Rails, how to organize code, how to write tests, how to use the features of the framework, how to avoid sharp edges, and how to follow Rails conventions, we’re doing ourselves and them a disservice. We cannot expect dependency violations to teach anyone how to design software for Rails applications.
Companies that are hiring developers to write Rails need to do more education. Part of that education comes in the form of ensuring every team has at least one person who knows Rails well. Having entire teams of developers from other languages who were never trained in Rails can’t possibly produce well-designed, idiomatic Rails code at scale. It’s not their fault—it’s ours because we failed to train them.
In addition to education, we need indoctrination. This is different from education because it goes beyond the technical basics of how to write Rails. We need to evangelize new developers coming into the framework. I remember back in the early days, we used to all give talks about how Rails is the way it is and how it promotes developer happiness, and we’ve kind of stopped doing that. As developers have more and more choices about what languages they can write, we need to show them the joy that Rails brings. You can be so productive if you’re following conventions and you know how to use the framework, but when your only experience with Rails is an application that’s deviated from conventions and is littered with technical debt, you might not be able to see the beauty of the framework. But without indoctrination—without getting our coworkers to fall in love with Rails like we did—all they see is another tool, another language. Rails is more than simply a Ruby framework, and I want others to feel the same joy I do when writing Rails applications.
Leaders need to re-prioritize quality. It’s easy to fall into the ship-faster trap, but waiting until shipping is painful to improve quality isn’t good for the application or team morale. When we ignore technical debt for a long time, it has a tendency to build up to a point where everything feels impossible. I’ve seen so many organizations wait until it’s too late. When that happens, we start searching for a silver bullet. We invest years in a path that won’t fix the problems we have because it’s easier than addressing the real issue—that we have an engineering culture that values shipping over quality, features over bug fixes, and magic solutions over fixing problems at their source.
Part of the quality issues come from incentives that value shipping features or silver bullets over targeted, methodical improvements. If improving performance, cleaning up technical debt, or upgrading Rails isn’t rewarded the same as shipping features, why would anyone in your engineering organization prioritize the grueling, unglamorous work of maintenance? It’s up to leaders to highlight that work and reward it with promotions and raises. We cannot possibly expect any improvement to code quality if refactoring and maintenance aren’t incentivized. Just because someone doesn’t say, "Look at me, I made this better," doesn’t mean they aren’t the ones keeping the lights on. Leaders need to both highlight and reward good work, especially when it’s not visible.
If you decide to modularize your Rails application in the future, there are some technical challenges to keep in mind. As we’ve discussed, it won’t fix human or cultural problems, but that doesn’t mean that a modular monolith won’t help with some of the issues in your application and organization.
First, if you modularize, start with the least number of packages possible. There’s no reason to decide every domain boundary upfront because you’re going to learn a lot about your application and product during this process. It’s also a lot easier to undo modularization if you have just a handful of packages and decide it doesn’t work than if you have 50+ packages, especially if they’re all nested. By making fewer packages upfront, you also avoid package obsession and ownership obsession. You don’t want to end up with an application design and architecture that mirrors your org chart, otherwise, you’ll spend more time moving files around than fixing technical debt.
When isolating a monolith, the focus should be on functional isolation rather than domain isolation. This means that instead of creating strict boundaries between every single concept, the focus is on where the application seams are. For example, in the Fur and Foliage application, we might find there’s staff admin functionality built into the monolith for running data migrations or looking up customer accounts. This is a great example of something that can be functionally isolated from the rest of your monolith. It’s not part of the core product, and it doesn’t affect customers, but you still need it to share code or share information.
By isolating on the functional level instead of the domain level, we can be more cognizant of avoiding indirection and primitive obsession. When modularizing your Rails applications, be sure not to do it prematurely. It’s hard to say when the right time is, but it’s certainly not when you have just a few thousand or a few hundred thousand lines of code. Instead of modularizing too early, spend time identifying and addressing technical debt, otherwise modularization can end up hiding poor design decisions for years and introducing worse patterns.
Remember that modularization does not fix organizational structure, nor does it improve existing technical debt. It’s important to fix problems at their source. This means that even if you modularize your Rails application, you still need to figure out why your CI is slow and flaky. You need to refactor tightly coupled code, reorganize poorly structured code, teach engineers how to write and design idiomatic Rails, and avoid a blame-based culture by ensuring maintenance work on technical debt is as valued and incentivized as feature work.
The real heroes in your organization are those who painstakingly work on improving bugs, performance issues, and ensuring that upgrades are done in a timely manner. Don’t fall for the sunk cost fallacy. You should never continue down a path that isn’t working just because it feels too hard to turn back. It’s important to re-evaluate architectural and technical solutions over time. What was right a few years ago might not work well today, and it’s okay for your goals to change.
At Shopify, when we started modularizing our monolith, our goal was to isolate packages, run them as separate CI builds, deploy them separately, and have each package owner do their Rails upgrade work. Over time, this has proven to not only be really difficult, but also not the right fit for our organization. We refocused our efforts on functional isolation, improving the developer experience, and removing checks that caused more friction than benefit in development. It’s important to have a healthy engineering culture that can critically assess what’s working and what isn’t, without feeling like pivoting is a sign of failure.
I’ve been writing Rails applications for 14 years, and I’ve seen a lot of different applications at varying stages of their evolution. I’ve spent significant time in the codebases of some of the very first Rails applications ever built. What we’ve seen over time is that as engineering organizations scale and applications grow, they eventually get to a state where the framework stops bringing developers joy. We find ourselves missing the productivity we had when we first started building our product. As development slows and joy is replaced with friction and frustration, we look for someone or something to blame.
When I hear developers talk about how Rails applications turn into a ball of mud, they make it sound like it’s inevitable. It’s very common for an application that’s millions of lines of code and worked on by thousands of engineers to feel like a big mess. They say something like, “Rails doesn’t provide patterns or tooling for managing the increasing complexity of a monolith.” But I don’t think the challenges we’ve looked at today are the responsibility of the Rails framework to solve. The truth is, the ball of mud is actually caused by an engineering culture that incentivizes shipping over code quality, hiring fast over education, and chasing silver bullets rather than rewarding those working on technical debt.
Why should Rails provide tooling for modularization when the complexity, lack of structure, and friction-filled development environments aren’t caused by the framework itself? Often, it seems that when we reach for modularization, we’re trying to engineer ourselves out of a large organization. We keep trying to find ways to make a 20-year-old monolith feel like a greenfield application, but it can’t because it’s not, and it shouldn’t be. It makes sense that, as developers, we want to reach for technology to solve our problems because that’s where we have control. But Rails can’t engineer us out of our problems, and neither can modularization, engines, Packwerk, or microservices.
This isn’t because modularity and isolation are bad; it’s because we’re trying to solve human and cultural problems by changing our architecture. At Shopify, we’ve been trying to engineer ourselves out of these problems for six years. Gusto, GitHub, Doximity, Zendesk, and others are trying as well. The truth is, this is kind of uncharted territory. We’re still trying to figure out how to make working on a monolith of this size bring joy to developers.
If we look at the current state of our applications, we’ll see that many of the problems we set out to solve are still ever-present, and new issues have cropped up. We want to blame our tools, our engineers, and our framework, but ultimately blame won’t help. We need to shift our engineering culture to value what’s really important in order to solve architectural, organizational, and operational issues.
Leaders in our companies and in our community need to do more. Engineering culture comes from the top. We have to start educating new hires not just on how to use Rails, but on software design and Rails philosophy as well. We need to prioritize fixing technical debt and invest in code quality, rather than rewarding only those who ship features. We need to start talking about why we love Rails and indoctrinate newcomers so they not only keep writing Rails but fall in love with it the way we did. We need more collaboration in our engineering organizations, rather than focusing on a blame-based development culture.
We need to be curious and discerning—don’t fall for the thinking that there’s a magic cure-all solution to these problems, because there isn’t. The myth of the modular monolith is that architecture cannot fix human and cultural problems, but fixing human and cultural problems can improve our architectural, operational, and organizational challenges.
And while AI technology is trying to eat our jobs and livelihoods, these human problems are only going to become more obvious and more important to solve. Refocusing on education, indoctrination, and collaboration will ultimately make us more successful at solving these hard problems. Let’s invest in our engineering culture, embrace our monoliths, and rediscover joy in programming Rails at a large scale.
Thank you.