There’s a version of the “should we keep our monolith” conversation that happens at 50k lines, again at 200k, and once more somewhere around 500k. By the time a codebase crosses a million lines of code, most of the theoretical debate has resolved itself, and what’s left is a set of very concrete engineering problems that architecture diagrams don’t capture well.
This piece from Semicolon & Sons compiles 113 pragmatic lessons from a developer’s path from tech lead to CTO while keeping a growing monolith alive and shipping. The list covers territory from deployment practices to team dynamics to database management. What strikes me reading through it is less any individual lesson and more the shape of the list as a whole: the problems that appear at this scale are almost all enforcement problems, not design problems.
Implicit Rules Are Technical Debt You Haven’t Measured
Every codebase starts with implicit conventions. The early engineers know where things go, how dependencies are supposed to flow, which modules are allowed to call which. This works fine at 10k lines because the entire codebase fits in one person’s head, and violations are caught in review because the reviewer holds the mental model.
At 100k lines, some conventions have already fractured into competing interpretations. Different teams have different understandings of where business logic belongs. The “we don’t call into that module directly” rule is honored by people who were there when the rule was made and ignored by people who joined later and never heard it.
At 1M lines, every undocumented rule has splintered. The codebase contains multiple incompatible conventions layered on top of each other, representing every era of the team’s history. A new engineer joining cannot infer the rules from the code because the code doesn’t consistently follow any single set of them.
The teams that successfully scale monoliths past this threshold are the ones who converted implicit rules into machine-enforceable constraints before the drift became too severe to correct.
The Tools That Make Enforcement Possible
The Ruby ecosystem has produced the clearest example of this principle in action. Shopify’s Packwerk is a static analyzer for Rails monoliths that enforces two things: package privacy, where modules declare which constants are public and which are internal, and explicit dependency declarations, where packages list the other packages they’re permitted to depend on. Violations are reported as static analysis failures, not suggestions.
The key design decision in Packwerk is that it doesn’t require you to fix everything at once. You run it against an existing codebase, generate a package_todo.yml that records all current violations as acknowledged technical debt, and then enforce “no new violations” from that point forward. The todo file shrinks over time as the team pays down the debt. This is the right approach to retrofitting enforcement on a large codebase; attempting to reach zero violations before enforcing anything is how enforcement projects stall indefinitely.
Similar patterns exist in other ecosystems. Java’s ArchUnit lets you write executable architecture rules as unit tests: no class in the infrastructure package should depend on a class in the domain package except through specified interfaces. These tests live in CI and fail the build when someone violates a layering rule, regardless of whether they knew the rule existed.
TypeScript monoliths can use tools like eslint-plugin-import to prohibit cross-module imports at the path level, forcing developers through public module APIs. The enforcement mechanism varies by language and ecosystem, but the common thread is the same: good architectural rules produce tooling. If a rule cannot be expressed to a linter or static analyzer, it’s probably not precise enough to be a real rule.
Build Time and Test Time Are the Concrete Killers
The most immediately painful thing about a 1M LOC monolith is usually not coupling or architecture, it’s feedback loops. A test suite that runs for 45 minutes serialized will not be run before every commit. When tests don’t run before commits, the tests stop reflecting the actual state of the codebase.
The solution is almost always parallelization, but naive parallelization of a large test suite surfaces hidden coupling. Tests that weren’t designed to run concurrently share global state, modify the same database rows, or depend on singleton objects that are not thread-safe. A test suite that passes when run in sequence begins to fail intermittently when split across workers.
Fixing test isolation is not glamorous work, but it’s what enables everything else. Once you can run your full test suite in under five minutes across a cluster, you can require CI to pass before merge, bisect regressions reliably, and refactor without fear. The compounding benefit of fast tests on a large codebase is difficult to overstate; it’s the infrastructure that makes all other improvement possible.
Stack Overflow’s architecture has been publicly documented several times. They’ve handled a significant fraction of the world’s developer traffic with a monolithic ASP.NET application backed by SQL Server, running on a small number of physical servers. Their position has always been that the monolith is an asset precisely because they’ve invested in the tooling, caching, and operational practice that makes it performant. The architecture choice and the investment in engineering discipline are not separable.
The Database Is Where Coupling Concentrates
In a modular monolith, the code can be organized into bounded contexts with enforced seams. The database is harder to partition. Shared tables become implicit contracts between modules. A foreign key from one domain’s table to another’s is a dependency that no amount of package visibility enforcement will remove.
The practical approach is to own your database organization as explicitly as you own your code organization. Tables should be namespaced by the module or bounded context that owns them. Cross-module foreign keys should be rare and explicit, not the default path of least resistance. When two modules share a table because it was convenient at the time, you’ve created a coupling that will resist every future refactoring, whether you’re splitting a service out or just reorganizing code.
Some teams take this further and enforce database ownership through separate schemas or separate database users with scoped permissions. The enforcement mechanism matters less than the clarity: every table has one owner, and other modules access that data through the owner’s API layer, not by reaching into the table directly.
What the List Adds Up To
Reading 113 pragmatic lessons from someone who scaled a real system is valuable not because each lesson is novel, but because the list reflects the actual distribution of problems at that scale. The lessons that appear most frequently are the operational ones: deploy small, keep the build fast, make violations visible immediately, document ownership, don’t let the database drift.
The architectural guidance that dominates conference talks, about event sourcing and CQRS and distributed sagas, appears at the edges. Those patterns have their place, but they’re not what separates a monolith that scales from one that doesn’t. The separation usually comes from something much more mundane: whether the team invested in enforcement tooling early enough that the codebase could still be steered when it mattered.
DHH’s “The Majestic Monolith” from 2016 held up better than many people expected. The subsequent years of microservices complexity hitting teams that adopted them prematurely shifted the industry’s center of gravity noticeably back toward well-structured monoliths. A maintained monolith with enforced internal structure is a defensible long-term architectural choice. The operative word is “maintained,” and maintenance at this scale is an engineering discipline that deserves the same deliberate investment as any other part of the system.