When one repository is enough, and when it isn't
Monorepo or polyrepo is one of the most consequential architecture decisions for Git projects, because it shapes commit behavior, access rights, CI/CD pipelines, and daily developer routine for years to come. This article compares both strategies against concrete criteria such as team size, coupling, and tooling, and shows how Magento agencies can sensibly split client projects and custom modules between app/code and their own Composer packages.
Table of Contents
- 1. Monorepo vs. Polyrepo: two basic models of repository organization
- 2. Monorepo: atomic commits and synchronized dependencies
- 3. Monorepo: repository growth, performance, and tooling limits
- 4. Polyrepo: clear ownership and independent releases
- 5. Polyrepo: coordinated PRs, dependency drift, and CI duplication
- 6. Team size and coupling as a decision criterion
- 7. Magento modules: app/code monorepo or standalone Composer packages?
- 8. Git tooling for monorepos: sparse-checkout and worktree
- 9. Monorepo vs. polyrepo head to head
- 10. Summary
- 11. FAQ
1. Monorepo vs. Polyrepo: two basic models of repository organization
A monorepo is a single Git repository that holds multiple projects, packages, or modules together, often with its own folder structure per subproject, but a single, shared commit history. A polyrepo approach instead distributes the same projects across many individual repositories, each with its own history, its own branches, and its own access control. Both models solve the same underlying problem, namely how to organize code across teams and services, but with fundamentally different trade-offs in coupling, tooling, and governance.
It's worth noting that a monorepo does not automatically mean a single deployable artifact. Google, Meta, and many other large companies run monorepos with thousands of independently buildable and deployable packages inside one repository. Conversely, polyrepo does not automatically mean clean decoupling, because tightly interwoven services in separate repos can still stay closely coupled, just without Git making that coupling visible. The decision is therefore less a question of right or wrong, and more a question of which trade-offs fit your own team structure and project landscape.
2. Monorepo: atomic commits and synchronized dependencies
The biggest practical advantage of a monorepo is the atomic commit across project boundaries: if the signature of a shared library changes, the library itself and all of its consumers can be adjusted and reviewed together in a single commit. There is no intermediate state where a consumer project compiles against an already outdated interface version, because the library and its callers never have to be versioned separately. This reduces breaking-change coordination to a single pull request review instead of a chain of coordinated releases across multiple repositories.
This leads to the second major advantage: dependencies are, by definition, always in sync. There is no scenario where project A is still stuck on version 1.2 of an internal library while project B has already migrated to version 2.0. Every checkout of the monorepo automatically delivers consistent, compatible versions of all included packages, which rules out entire classes of integration bugs and version conflicts from the start, without requiring separate internal package registry management.
3. Monorepo: repository growth, performance, and tooling limits
The downside shows up as size grows. A repository that accumulates every project, every asset, and their complete history over years inevitably becomes large, and git clone, git status, and git log get noticeably slower, because Git by default loads the entire history and the entire working tree. Beyond a certain size, a plain git clone is no longer enough; it takes targeted use of --depth, --filter=blob:none, or sparse-checkout configuration to stay practical.
Another problem is granular access control: Git has no built-in concept of folder-level permissions within a repository. If an external freelance team is only supposed to work on one subproject but must not have access to the rest of the codebase, a pure monorepo can only approximate that with additional tools like GitHub CODEOWNERS combined with server-side branch protection rules, never cleanly enforced at the filesystem level. CI pipelines also have to be actively scoped to changed paths, otherwise every change reflexively rebuilds the entire repository.
4. Polyrepo: clear ownership and independent releases
A polyrepo approach makes ownership technically enforceable instead of merely organizationally agreed on: every repository has its own access rights, its own branch protection rules, and its own CODEOWNERS, so a team or an external contractor gets access to exactly, and only, its own project. This one-to-one relationship between repository and area of responsibility considerably simplifies audits, offboarding, and granting access to external contract partners, because revoking a single access grant never accidentally affects other projects.
Since each repository only contains its own code, clone and checkout times stay small permanently, no matter how many other projects exist across the company. Polyrepo also allows genuinely independent versioning and release cycles: one service can deploy daily, another quarterly, without a shared tag or branch state in the same repository artificially synchronizing both. For loosely coupled microservices or completely separate client projects, this is often the more natural fit.
5. Polyrepo: coordinated PRs, dependency drift, and CI duplication
The price for this separation shows up with cross-project changes. A refactor that touches a shared library and five dependent services requires, in a polyrepo model, five plus one coordinated pull requests, each with its own review, its own CI pipeline, and its own merge timing. Without a strict order and without feature flags, intermediate states inevitably occur where some services already use the new library version and others don't yet, which complicates test coverage and debugging.
This results in dependency drift: without active version management, projects drift apart over time, because nobody centrally enforces that every consumer updates promptly to the latest internal library version. On top of that, CI/CD configuration, such as linting rules, test runner setup, or deployment scripts, has to be maintained separately in every repository, which with ten or twenty repositories quickly leads to inconsistent, diverging pipelines unless it's counteracted centrally with reusable CI templates or a shared actions library.
6. Team size and coupling as a decision criterion
The practical decision rule hinges on two factors: team size and actual coupling between the projects, not ideology. A small team maintaining a single, tightly coupled Magento 2 project including several custom modules almost always benefits from a monorepo, or at least a very small number of repositories, because the coordination overhead of multiple repos clearly outweighs the benefit of separation at this scale. A small team's core strength lies in working quickly across module boundaries, not in fine-grained access control.
The reverse is also true: several independent client projects with different deployment cycles, different contract partners, and no shared code at all belong in separate repositories, even for a small team. Loosely coupled services with their own release rhythms follow the same logic. A simple test helps with the classification: does a commit typically touch several projects at once? That favors monorepo. Does a commit almost always touch only a single, isolated project? That favors polyrepo.
7. Magento modules: app/code monorepo or standalone Composer packages?
For a Magento agency, this question gets very concrete: should custom modules for a client project live directly inside the app/code directory of the main Magento repository, monorepo-style, or as standalone Composer packages in separate repositories wired in via composer.json, polyrepo-style? For modules that are inseparably tied to exactly one client project, such as a custom checkout feature, app/code in the main repository is almost always the right choice: no version management needed, no Composer repository configuration, immediate visibility in the code review of the overall project.
Modules that get reused across multiple client projects, such as a generic SEO extension or a cookie consent module, on the other hand belong in their own Composer package with its own repository and semantic versioning. Only that way can a bug fix be rolled out to all client projects in a targeted manner without manually copying files for each client, and only that way does it stay traceable which version of a reusable module is active in which client project.
# Example monorepo layout for a Magento agency: shared core in vendor,
# client-specific customizations in app/code, one Git history for all
project-monorepo/
├── app/
│ └── code/
│ └── Mironsoft/
│ ├── ClientCheckout/ # tightly coupled to this one client
│ └── ClientPricing/ # tightly coupled to this one client
├── vendor/
│ └── mironsoft/
│ └── seo-suite/ # reusable, installed via Composer
├── composer.json
└── .git/ # single shared history
{
"name": "mironsoft/seo-suite",
"description": "Reusable SEO module, versioned and released independently of any single client project",
"type": "magento2-module",
"require": {
"php": "^8.4",
"magento/framework": "^103.0"
},
"autoload": {
"psr-4": {
"Mironsoft\\SeoSuite\\": "src/"
}
},
"extra": {
"magento": {
"module": "Mironsoft_SeoSuite"
}
}
}
8. Git tooling for monorepos: sparse-checkout and worktree
git sparse-checkout solves the problem that a developer in a large monorepo rarely needs every folder at once. Instead of checking out the complete working tree, sparse-checkout defines exactly the paths that get materialized in the local working directory, while the rest of the history stays in the object store but is not checked out to disk. Combined with git clone --filter=blob:none (partial clone), this additionally prevents blob content for unneeded paths from ever being downloaded in the first place, which drastically reduces clone times for very large repositories.
git worktree solves a different but related problem: working on several branches at once inside a monorepo, for example a hotfix for client project A alongside a feature for client project B, without constantly switching with git stash and git checkout. Each worktree is its own working directory with its own checked-out branch that shares the same .git object database, so no second full clone is needed and disk space is saved.
# git sparse-checkout: only materialize the paths this developer needs
$ git clone --filter=blob:none --sparse git@github.com:mironsoft/monorepo.git
$ cd monorepo
$ git sparse-checkout set app/code/Mironsoft/ClientCheckout vendor/mironsoft/seo-suite
# Working directory now only contains the two paths above,
# full history stays available in .git without a full checkout
$ git sparse-checkout list
app/code/Mironsoft/ClientCheckout
vendor/mironsoft/seo-suite
# git worktree: work on a hotfix and a feature branch at the same time,
# without stashing, sharing one .git object database
$ git worktree add ../monorepo-hotfix hotfix/client-a-checkout-bug
$ git worktree add ../monorepo-feature feature/client-b-pricing-rules
$ git worktree list
/home/dev/monorepo a1b2c3d [main]
/home/dev/monorepo-hotfix e4f5g6h [hotfix/client-a-checkout-bug]
/home/dev/monorepo-feature i7j8k9l [feature/client-b-pricing-rules]
9. Monorepo vs. polyrepo head to head
In a monorepo, every CI pipeline by default risks building and testing the entire repository on every commit, even if only a single line changed in an isolated module. Modern CI systems solve this with path filtering: the pipeline trigger checks, via git diff against the target branch, which paths are affected, and starts only the jobs whose assigned paths actually changed. That keeps CI runtime proportional to the change, even in a large monorepo, rather than proportional to the overall size of the repository.
# GitHub Actions: scope a job to changed paths only, monorepo-style
name: ci
on: [pull_request]
jobs:
changes:
runs-on: ubuntu-latest
outputs:
checkout: ${{ steps.filter.outputs.checkout }}
seo-suite: ${{ steps.filter.outputs.seo-suite }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
checkout:
- 'app/code/Mironsoft/ClientCheckout/**'
seo-suite:
- 'vendor/mironsoft/seo-suite/**'
test-checkout-module:
needs: changes
if: needs.changes.outputs.checkout == 'true'
runs-on: ubuntu-latest
steps:
- run: bin/phpunit app/code/Mironsoft/ClientCheckout
The following table compares both strategies along the dimensions that are most noticeable in day-to-day development work.
| Dimension | Monorepo | Polyrepo | Recommendation |
|---|---|---|---|
| Cross-project refactoring | One atomic commit | Several coordinated PRs | Monorepo for tight coupling |
| CI speed without path filtering | Often rebuilds everything | Automatically only its own repo | Path filtering is mandatory in a monorepo |
| Access control | Only approximable with extra tools | Native per repository | Polyrepo for external teams |
| Onboarding new developers | One clone, one context | Many repos, many setups | Monorepo for small teams |
| Dependency consistency | Always in sync | Dependency drift possible | Monorepo for shared libraries |
Mironsoft
Repository strategy, Git workflows, and CI/CD pipelines for PHP and Magento teams
Deciding between monorepo or polyrepo for your project?
We analyze your project landscape and team structure and help you decide between monorepo and polyrepo, including sparse-checkout setup, CI path filtering, and clean Composer packaging of reusable Magento modules.
Repository Audit
Analyze the existing repo structure and align it with coupling and team size
Git Tooling Setup
Set up sparse-checkout, worktree, and Composer packaging for Magento modules
CI/CD Path Filtering
Scope pipelines to changed paths instead of rebuilding everything every time
10. Summary
A monorepo bundles multiple projects into a single repository with shared history, which enables atomic cross-project commits and permanently synchronized dependencies, but demands targeted tooling like sparse-checkout, partial clone, and CI path filtering as it grows. A polyrepo approach enforces clear ownership boundaries and allows independent release cycles per project, but pays for that with coordinated pull requests across multiple repositories for cross-project changes and the risk of dependency drift.
For a Magento agency, that means in practice: client-specific custom modules typically belong monorepo-style in app/code of the main project, while modules reused across multiple clients belong as standalone Composer packages in separate repositories. The decision doesn't follow a rigid dogma, but the actual coupling of the projects and the size of the team maintaining them.
Monorepo vs. Polyrepo: the key points at a glance
Monorepo
Atomic commits across projects, always synchronized dependencies, but growing repo size and tooling needs.
Polyrepo
Clear ownership per repository, independent releases, but coordinated PRs and risk of dependency drift.
Decision criterion
Team size and actual coupling of the projects, not ideology or trend.
Magento practice
Client-specific in app/code, reusable as its own Composer package.