Overview
Past about 100,000 lines of code, the patterns that worked at 10,000 stop scaling. Every developer steps on every other developer; CI takes too long; nobody owns the messy bits. The rules below cover the structural moves that buy back legibility: ownership at the folder level, the modular-monolith vs microservices decision, decomposition by data, build and test sharding, and import paths that survive IDE indexers. They build on folder-hierarchy and monorepo.
Decompose by data ownership, not by technical layer
The biggest decomposition mistake at scale is splitting by layer (web/, api/, workers/, db/). Every feature change touches every layer and teams collide on every PR.
Decompose by data ownership instead. The team that writes a Subscription row owns the model, the API that exposes it, the worker that processes it, and the migration that evolves it.
- Bad:
payments-api+payments-worker+payments-db-migrationsas separate codebases owned jointly. - Better: a
payments/module that owns its API surface, background jobs, and schema end to end.
The team boundary tracks the data boundary. Conway’s Law works whether you want it to or not.
CODEOWNERS at the folder level
A 100k-LoC repo without CODEOWNERS turns every PR into a guessing game. List ownership per top-level module:
# .github/CODEOWNERS
/packages/payments/ @org/payments-team
/packages/identity/ @org/identity-team
/packages/notifications/ @org/growth-team
/infra/ @org/platform-team
/.github/ @org/platform-team
# Default fallback
* @org/maintainers
Rules that hold up:
- One folder, one owning team. Joint ownership creates the same diffusion of responsibility that no-ownership did.
- The default fallback (
*) catches new files. Without it, untouched paths have no reviewer. - Required reviews from owners are enforced via branch protection (see github).
Modular monolith first, microservices when forced
Reach for microservices only when a concrete constraint forces them: an independent deploy cadence, a workload that needs different scaling, or a regulatory boundary that needs process isolation.
Default to a modular monolith with strict internal boundaries:
- One deployable artifact, many internal modules.
- Modules import each other through public interfaces only (see folder-hierarchy).
- A single migration story, a single observability story, a single deploy.
A modular monolith preserves the option to extract a microservice later. The reverse is much harder. See monorepo.
Enforce module boundaries in tooling
Convention is not enough at scale. Wire boundaries into the toolchain so a cross-module shortcut fails CI.
- TypeScript: ESLint
no-restricted-importsoreslint-plugin-boundaries. - Python: a custom
ruffrule, or apytesttest that imports every module and asserts the graph. - Go:
internal/is compiler-enforced; lean on it. - Java:
ArchUnittests for layer rules. - Build graph: Bazel or Nx refuse to build if a
visibilityconstraint is violated.
A boundary that lives only in a wiki gets crossed within a quarter.
Shard the build and the test suite
A 30-minute CI run kills productivity. At 100k LoC, sharding is mandatory.
- Build sharding. Only rebuild modules whose inputs changed. Turborepo, Nx, Bazel, and
gradle --build-cachesupport this. A CI run that does nothing should finish in under a minute. - Test sharding. Split the suite across parallel CI workers via GitHub Actions matrix, pytest-xdist, Jest
--shard, or Go’s-parallelflag. - Affected-only on PRs. PR CI runs tests for modules the diff touched; nightly CI runs the full suite.
Build a cache hit-rate dashboard. A cold cache on every PR means the cache is misconfigured.
IDE-friendly imports
At 100k LoC, IDE indexing performance is a feature. Import style affects it directly.
- Use absolute imports from a configured root (
@org/payments/...), not deep relatives (../../../../payments/...). - Avoid barrel files at the module edge. They wreck tree-shaking and confuse “go to definition.”
- Per-module [[coding/typescript-tsconfig|
tsconfig.jsonwithreferences]] lets TypeScript build incrementally. - For Python,
from billing import invoicereads better thanfrom billing.invoice import *.
Slow IDE means slow developer. Fix the indexer with the same urgency as a slow test suite.
Migrate in linear steps, not flag days
When a 100k-LoC schema or API change has to land, the temptation is to coordinate a flag day. Do not.
- Land the additive change first (new column, new endpoint). Deploy.
- Migrate readers and writers incrementally. Deploy each.
- Remove the old path last. Deploy.
Each step is its own PR with its own owners. Feature flags live in a known location; migrations live in a numbered directory; deprecated paths carry a “remove after