Managing 15 Teams on One Monorepo

Notes from running a multi-team Nx monorepo. CODEOWNERS, module boundaries, affected-only CI, and the day Conway's Law stopped being a theory.

It was a Wednesday around 11 a.m. when our shared design-tokens package broke the build for what felt like half the company. A two-line change to a color variable had cascaded through the dependency graph and pinned every downstream app on red. The war-room thread filled with people from five teams in under ten minutes. Nobody on the thread owned the tokens package. Or rather, everyone did, which is the same thing.

That was the day I stopped treating our monorepo as a code-organization tool and started treating it as an org-chart problem.

I’ve been running multi-team Nx monorepos in different shapes for years now, at a creator-economy platform I worked at and at a couple of side products I CTO on the side. The pattern below is what I’d bring to a new monorepo on day one. If you’ve got fifteen teams in one repo and no CODEOWNERS, no module boundaries, and a CI that runs everything on every PR, the article is for you.

How three teams became fifteen

We started small. Three squads, a couple of shared packages, an apps/ directory and a libs/ directory. Code review was a Slack DM. Then growth happened, new squads kept spinning up, and at some point we had over a dozen teams committing into the same repo every day. No one announced “we are now fifteen teams”. It just happened.

What also happened: the directory tree quietly turned into a map of the org chart. Mobile lived in apps/mobile/*. Billing under libs/billing/*. Community had its own corner with its own conventions. Conway’s Law showed up not as a metaphor but as a literal find output. The bad version of this is when nobody plans for it. You end up with libs/shared/utils/ that’s been imported by every team and modified by none of them, and a CI run that takes the better part of an hour. That was us, roughly month nine.

CODEOWNERS as a contract

The single most useful thing we did was turn CODEOWNERS into a strict gate, not a hint. Before that, PRs sat for days because nobody knew who to tag. After, the right reviewers were auto-requested the moment the PR opened.

The rule I’d push for: every path has an owner, and the wildcard fallback owner is a small platform group, not “everyone”.

# CODEOWNERS
# Order matters - last match wins.

*                              @org/platform-leads

/apps/web/                     @org/team-web
/apps/mobile/                  @org/team-mobile
/apps/admin/                   @org/team-admin

/libs/billing/                 @org/team-billing
/libs/community/               @org/team-community
/libs/notifications/           @org/team-notifications

# Shared packages have stricter ownership.
# Changes here require platform AND the consuming team that depends on it most.
/libs/ui-tokens/               @org/team-design-system @org/platform-leads
/libs/auth-client/             @org/team-identity     @org/platform-leads

# CI and tooling - platform only.
/.github/                      @org/platform-leads
/tools/                        @org/platform-leads
/nx.json                       @org/platform-leads
/tsconfig.base.json            @org/platform-leads

The wildcard at the top catches anything that escapes the more specific rules. Shared packages get two owners, deliberately, so one team can’t unilaterally change something half the org depends on. And CI config lives behind platform review, because the day a team breaks the GitHub Actions workflow file is the day every other team’s CI breaks too.

Module boundaries via Nx tags

CODEOWNERS handles the social side. Module boundaries handle the code side. Without them, “shared” becomes a graveyard, and one team’s helper function gets imported by ten other teams who didn’t ask for it.

The Nx model is to tag every project, then write an ESLint rule that says which tags can depend on which.

// .eslintrc.json (root)
{
  "overrides": [
    {
      "files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
      "rules": {
        "@nx/enforce-module-boundaries": [
          "error",
          {
            "enforceBuildableLibDependency": true,
            "allow": [],
            "depConstraints": [
              {
                "sourceTag": "scope:app",
                "onlyDependOnLibsWithTags": ["scope:app", "scope:shared"]
              },
              {
                "sourceTag": "scope:shared",
                "onlyDependOnLibsWithTags": ["scope:shared"]
              },
              {
                "sourceTag": "type:feature",
                "onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:data-access", "type:util"]
              },
              {
                "sourceTag": "type:ui",
                "onlyDependOnLibsWithTags": ["type:ui", "type:util"]
              },
              {
                "sourceTag": "type:util",
                "onlyDependOnLibsWithTags": ["type:util"]
              }
            ]
          }
        ]
      }
    }
  ]
}

The point isn’t the specific tags. The point is that “can team A import team B’s internals” becomes a lint error at the keyboard, not a code-review conversation three days later. We caught dozens of accidental cross-team imports the first week we turned this on. A lot of them were honest mistakes, the kind VS Code’s auto-import will gladly make for you at 4 p.m. on a Thursday.

Affected-only CI for the monorepo

A monorepo CI that runs everything on every PR is fine at three teams. It is not fine at fifteen. We were sitting at around 40 minutes per PR for build, test, lint, type-check. With everyone pushing, the Actions queue was always backed up.

Nx’s affected graph is the answer. The base of affected is the PR target (usually main), and Nx figures out which projects changed and which ones depend on them transitively.

# .github/workflows/ci.yml
name: ci

on:
  pull_request:
    branches: [main]

jobs:
  affected:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: pnpm/action-setup@v3
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Derive Nx base/head
        uses: nrwl/nx-set-shas@v4

      - name: Lint affected
        run: pnpm nx affected -t lint --parallel=3

      - name: Type-check affected
        run: pnpm nx affected -t typecheck --parallel=3

      - name: Test affected
        run: pnpm nx affected -t test --parallel=3 --ci

      - name: Build affected
        run: pnpm nx affected -t build --parallel=3

The nrwl/nx-set-shas action picks the right base and head for the affected computation. Without it you’ll either rebuild too much or miss things that should have been rebuilt. PR CI dropped from 40 minutes to under 8 once we cut over and added remote caching on top.

RFCs for shared packages

The piece that took longest for me to accept is the social one. Tools catch what tools can catch. Tools cannot catch “this change to the tokens package is going to make the mobile team’s app look weird”. Only the mobile team can catch that, and only if you tell them in advance.

We landed on a lightweight RFC. One page, async, owning team makes the final call. The point isn’t the document, it’s the comment window.

# RFC: <shared-package-name> - <short title>

Author: @<owner>
Owning team: @org/team-design-system
Consuming teams (auto): @org/team-web @org/team-mobile @org/team-admin
Comment window: 48 hours from posting in #monorepo-rfcs

## What is changing
One paragraph. Be concrete.

## Why
One paragraph. Why now, why this shape.

## Migration
What consumers need to do. Code-mod if any. Deprecation window.

## Risk
Worst-case blast radius. What we're explicitly not changing.

## Open questions
Things you want pushback on.

The 48 hour window is deliberate. Long enough for a team in another time zone to weigh in, short enough that you don’t lose momentum. Skipping the RFC is fine for tiny changes by convention, but the moment you’re touching public exports of a shared package you write one. Costs fifteen minutes. Saves you a Wednesday.

Conway’s Law made concrete

A few months in, I drew the dependency graph next to the org chart on a whiteboard and they were the same picture. The packages with the most cross-team edges, the design tokens, the auth client, the shared API client, were exactly the packages we’d had the most production pain around. After this, Conway’s Law stopped being a thing senior engineers say at conferences and became a constraint to design around.

What I’d do differently

Seed CODEOWNERS on day one with a “platform-leads” wildcard so nothing is unowned. Turn on Nx module boundaries before the second package lands. Set up affected-only CI before the third app. Write the RFC template into docs/ before any team needs it. The retrofit version of all of this, the version where the monorepo has been running unbounded for a year and you’re trying to roll boundaries in afterwards, is a quarter-long project nobody wanted. I’ve done that version. Don’t do that version.

Takeaways

  • CODEOWNERS is the strongest cheap tool you’ve got in a multi-team monorepo. Tighten it until every path has an owner.
  • Module boundaries on day one. Adding them after the fact is a migration project.
  • Affected-only CI is non-negotiable past about five teams. Pair it with remote caching.
  • Shared packages need a lightweight RFC. One page. Forty-eight hours. Owning team calls it.
  • Conway’s Law wins. Build coordination tooling where the coordination is going to happen anyway.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.