The internet will tell you Git submodules are a mistake. The internet has never tried to unify three enterprise .NET applications with 160+ projects, three separate GitLab CI pipelines, independent release cadences, and a team of 20 developers who are already productive in the current setup.
I did. And submodules were the right call.
The problem that triggered this
I work on an enterprise platform in a regulated industry. The system spans three repositories: a legacy web app (.NET Framework 4.8), a modern API (.NET 8), and a second API handling orchestration (.NET 8). They share a single SQL Server database and almost every significant feature touches at least two of them.
That meant juggling three branch strategies with no coordination. AI assistants couldn’t see across repo boundaries — they’d suggest patterns that made sense in one codebase but violated conventions in another. Setting up development tooling had to be repeated three times. And the real killer: the cognitive load of mentally reloading context every time you switched repos.
The cost wasn’t lines of duplicated code. It was duplicated thinking.
Three options, one constraint
I evaluated three paths:
Option 1: Full monorepo merge. Import all three repos’ Git histories into one. The textbook answer. Also the one that completely breaks three CI pipelines, three branching strategies, and the daily workflow of 20 engineers — overnight. In a regulated environment where we can’t afford deployment disruption, “overnight” is a non-starter.
Option 2: Keep repos separate, add shared tooling. A docs wiki, some scripts, maybe a shared library feed. Doesn’t solve the AI context problem. Doesn’t give you nx affected build intelligence across boundaries. Just polishes the existing pain.
Option 3: Nx monorepo with Git submodules as a stepping stone. The parent repo provides unified tooling, centralized documentation, and cross-project AI context. The app repos stay independent under apps/. Migrate gradually over three phases.
The constraint that decided it: zero disruption. A developer who wants to work only in the API repo still clones it directly. The monorepo is additive, not mandatory. Nobody has to change their workflow until they want to.
What I actually built
The parent Nx monorepo wires everything together. Three .gitmodules entries link the app repos under apps/. Nx auto-detects all 160+ .NET projects via @nx/dotnet and provides unified nx run and nx affected commands across the full surface area.
On top of the structural wiring, I built a centralized docs/ layer — a master project_context.md with the conventions AI agents must follow (DateTimeOffset not DateTime, soft-delete patterns, service layer rules, EF6/EF Core boundaries), integration architecture docs, and per-system subdirectories. Both humans and AI agents load this context at session start.
The hardest piece was cross-repo feature coordination. A single init-feature command takes a Jira ticket ID and creates matching branches in the parent repo and all three submodules simultaneously. An active-feature.yaml file persists the current feature context so AI agents auto-detect which ticket they’re working on from the branch name. No context handoff. No “which repo are we in?” confusion.
Merge order matters: submodule repos first, then the parent. Get this wrong and the parent points at stale submodule commits. It’s documented, it’s in the workflow, and it works.
The trade-offs I accepted
Submodules have real friction. Developers run git submodule update --init --recursive on first clone. The detached HEAD state inside submodules confuses some Git clients. Branch synchronization requires discipline — if someone creates a feature branch manually in one repo without using init-feature, the context is incomplete.
I accepted all of this because the alternative — disrupting a working team to satisfy an architectural ideal — was worse. The submodule complexity is documented, tooled around, and contained. The cognitive load it eliminates is not.
The three-phase plan
This was never meant to be permanent:
- Phase 1 (done): Submodule wiring, centralized docs, BMAD framework, cross-project AI tooling, feature context system
- Phase 2 (future): Move shared libraries to monorepo root
- Phase 3 (future): Remove submodules entirely, full integration
Phase 1 delivered immediate value. Phase 3 might happen in a year, or it might not happen at all — because Phase 1 might turn out to be enough. And that’s fine. The architecture serves the team, not the other way around.
What I’d tell you
If you’re evaluating monorepo vs. polyrepo, don’t just count lines of duplicated code. Count the number of times your developers mentally reload context when working across boundaries. That cognitive duplication is harder to measure and just as expensive.
And if the textbook answer requires disrupting a productive team overnight — question the textbook. The best architectural decision is the one your team can actually adopt.