How to structure a monorepo for scale
Practical decisions for teams choosing between mono and poly repo architectures.
By git11 AI · Sun Feb 15 2026 · 7 min read
{
"title": "The Hermetic Isolation Problem: Scaling Monorepos Beyond Dependency Hell",
"subtitle": "A deep dive into the architectural trade-offs between unified versioning, build caching, and boundary enforcement in large-scale codebases.",
"category": "education",
"read_time": 12,
"tags": ["monorepo", "architecture", "build-systems", "ci-cd", "software-engineering"],
"content": "## Introduction\n\nAs engineering organizations grow, the friction between code sharing and deployment autonomy becomes a primary bottleneck. The monorepo—a single repository containing multiple distinct projects—is often proposed as the solution to dependency fragmentation. However, without strict architectural constraints, a monorepo frequently devolves into a \"distributed monolith,\" where a single change in a shared utility library triggers an uncontrollable cascade of rebuilds and deployments.\n\nScaling a monorepo requires more than just moving folders into a single root. It requires a shift toward hermetic build systems, explicit boundary enforcement, and sophisticated CI orchestration. This guide examines the technical requirements for structuring a monorepo that remains performant at the scale of hundreds of engineers and millions of lines of code.\n\n## 1. The Anatomy of a Scalable Workspace\n\nA scalable monorepo must differentiate between three types of code: **Applications**, **Internal Libraries**, and **Tooling**. A common failure mode is an unstructured `packages/` directory where circular dependencies go unnoticed.\n\n### The Recommended Directory Structure\n\n```text\n. \n├── apps/ # Deployable units (Web, API, Mobile)\n├── libs/ \n│ ├── shared/ # Truly universal logic (types, constants)\n│ ├── domain-a/ # Domain-specific logic\n│ └── ui-kit/ # Design system components\n├── tools/ # Custom build scripts, linters, generators\n├── nx.json | turbo.json # Workspace configuration\n└── package.json # Root manifest with workspace definitions\n```\n\n### Workspace Definitions\nModern package managers (pnpm, Yarn, NPM) use a `workspaces` field to manage local symlinking. In a large-scale environment, `pnpm` is generally preferred for its content-addressable store, which prevents the \"phantom dependency\" problem—where a package can import a module it hasn't explicitly listed in its own `package.json` because it was hoisted from a sibling.\n\n## 2. Dependency Graph Enforcement\n\nThe greatest risk in a monorepo is the \"Big Ball of Mud\"—where every library depends on every other library. To prevent this, you must implement a directed acyclic graph (DAG) and enforce it via linting.\n\n### Layered Architecture\nWe define strict rules for imports based on layers:\n1. **Apps** can depend on **Libs**.\n2. **Libs** can depend on other **Libs** of a lower order.\n3. **Libs** cannot depend on **Apps**.\n4. **Domain Libs** cannot depend on other **Domain Libs** (enforce cross-domain communication via shared interfaces).\n\n### ESLint Boundary Rules\nUsing tools like `eslint-plugin-import` or Nx's `tags`, you can restrict access programmatically. \n\n```json\n// Example: Restricting imports via Nx tags\n\"@nx/enforce-module-boundaries\": [\n \"error\",\n {\n \"depConstraints\": [\n { \"sourceTag\": \"type:app\", \"onlyDependOnLibsWithTags\": [\"type:lib\", \"type:ui\"] },\n { \"sourceTag\": \"type:ui\", \"onlyDependOnLibsWithTags\": [\"type:shared\"] },\n { \"sourceTag\": \"domain:marketing\", \"onlyDependOnLibsWithTags\": [\"domain:marketing\", \"shared\"] }\n ]\n }\n]\n```\n\nThis prevents a developer in the `marketing` domain from accidentally importing high-privilege logic from the `billing` domain, even though they reside in the same physical repository.\n\n## 3. Hermetic Builds and Remote Caching\n\nIn a polyrepo setup, the build time is distributed across repositories. In a monorepo, total build time grows linearly with the codebase unless you use **Incremental Builds**.\n\n### The Build Cache\nA build system (such as Bazel, Buck, or Turborepo) hashes the inputs of a task (source files, environment variables, and dependencies). If the hash matches the previous run, the system restores the output from the cache instead of re-executing the task.\n\n```bash\n# Turborepo hash calculation example\nHash = hash(file_contents + internal_deps + global_env_vars)\n```\n\nFor a team of 50+ engineers, local caching is insufficient. You must implement a **Remote Cache**. This allows a CI agent to build a library and a developer to \"pull\" that build artifact minutes later without ever running a compiler locally. In large organizations, remote caching can reduce CI minutes by 40-70%.\n\n## 4. CI Orchestration: The 'Affected' Strategy\n\nRunning all tests on every Pull Request is unsustainable at scale. If your repository contains 100 packages, a change to a documentation file in `apps/docs` should not trigger unit tests in `libs/payment-gateway`.\n\n### Graph-Based Execution\nModern tools analyze the Git diff to determine which node in the graph changed. They then traverse the DAG upwards to find all impacted consumers.\n\n```bash\n# Only run tests for packages affected by the current branch compared to main\nnx affected:test --base=main --head=HEAD\n```\n\n### Change Detection Nuances\nWait-times in CI are often caused by \"false positives\" in change detection. For example, changing a global `prettier` config technically affects every file. To mitigate this, define \"Ignored Files\" and \"Global Inputs\" carefully. Using a tool like **git11** can help here by providing deeper insights into how code changes actually ripple through the dependency tree, allowing for more granular impact analysis than simple file-path matching.\n\n## 5. Versioning and Release Management\n\nThere are two schools of thought on monorepo versioning:\n1. **Unified (Synchronized) Versioning:** Every package gets the same version number (e.g., Babel, Jest). This simplifies compatibility but forces redundant releases.\n2. **Independent Versioning:** Each package has its own semver. This is more flexible but creates a \"version mismatch\" nightmare during local development.\n\n### The 'One Version' Rule\nRegardless of the release version, the internal repository should follow the \"One Version\" rule. This means there is only one version of any third-party dependency (e.g., `react` or `lodash`) for the entire monorepo. This prevents the \"multiple React instances\" bug and ensures that all internal libraries are compatible with each other at the current `HEAD` commit.\n\n## 6. Managing Large Volumes of Data (VFS and LFS)\n\nAs the `.git` folder grows, commands like `git status` or `git fetch` become sluggish. At the scale of several gigabytes of history, standard Git operations fail.\n\n### Solutions for Repository Size\n- **Git Sparse Checkout:** Allows developers to clone the entire repo but only check out the directories relevant to their current task.\n- **Git LFS (Large File Storage):** Moves binary assets (images, PDFs) out of the main git object store.\n- **Scalable Virtual File Systems:** For organizations with massive scale (Google, Microsoft), tools like VFSForGit (formerly GVFS) allow the OS to mount the repository as a virtual drive, fetching files only as they are accessed by the IDE or compiler.\n\n## 7. CODEOWNERS and Governance\n\nIn a monorepo, the danger is that a developer in `Team A` can modify code in `Team B`'s directory. While collaboration is encouraged, ownership is required for stability.\n\nUse a `CODEOWNERS` file in the root to enforce mandatory reviews for specific paths:\n\n```text\n# Global platform team owns the build config\n/tools/ @org/platform-engineers\n\n# Domain specific ownership\n/apps/billing/ @org/billing-team\n/libs/billing-logic/ @org/billing-team\n```\n\nGitHub and GitLab will automatically add these teams as required reviewers when files in these directory patterns are modified. This maintains the speed of a monorepo while preserving the accountability of a polyrepo.\n\n## 8. Migration Strategies\n\nMoving to a monorepo is rarely an atomic event. The most successful migrations use the \"Bridge\" strategy:\n1. **Identify a 'Greenfield' Library:** Start by moving shared types or simple utilities into the monorepo.\n2. **Publish to Registry:** Continue publishing these to your internal NPM or Maven registry so existing polyrepos can still consume them.\n3. **Incremental Ingestion:** Move applications one by one. Once an application is inside the monorepo, change its dependency from the registry-version to the workspace-version.\n\n## Conclusion\n\nA monorepo is not a silver bullet for organizational complexity; it is a trade-off that swaps dependency management friction for build system complexity. To scale effectively, prioritize:\n- **Hermeticity:** Ensure builds are deterministic and cached.\n- **Isolation:** Use linting to prevent a tangled dependency graph.\n- **Impact Analysis:** Only run what is necessary in CI.\n\nBy treating the repository as a platform rather than a folder, organizations can achieve a balance between shared code velocity and deployment stability."
}