Skip links

The Case for Monorepos in Small Companies

We spent eighteen months running Harbor Software’s codebase as separate repositories: one for the API, one for the frontend, one for shared libraries, one for infrastructure, one for the admin dashboard, and several more for smaller services and tools. We had 12 repositories for a team of 8 engineers. Cross-repository changes were a coordination nightmare. Keeping shared libraries in sync required manual version bumping, which nobody remembered to do promptly. A single feature that touched the API and frontend required two PRs, two CI pipelines, two code reviews, and careful coordination to deploy in the right order because the frontend depended on an API endpoint that did not exist yet.

Article Overview

The Case for Monorepos in Small Companies

7 sections · Reading flow

01
The Problem with Polyrepos at Small Scale
02
Monorepo Structure That Scales
03
Turborepo: The Engine That Makes It Work
04
CI/CD in a Monorepo
05
The Migration Path
06
When Monorepos Are Wrong
07
Developer Experience Improvements We Did Not…

HARBOR SOFTWARE · Engineering Insights

Six months ago, we consolidated everything into a single monorepo managed by Turborepo. The migration took two weeks. Developer velocity increased measurably within the first month. Feature delivery time dropped by roughly 30%. Cross-cutting changes went from multi-day coordination exercises to single-PR affairs. Here is the case for monorepos in small companies, how we made the transition, and the tooling that makes it practical.

The Problem with Polyrepos at Small Scale

The polyrepo model (one repository per service or project) works well for organizations with hundreds of engineers and dedicated platform teams. At that scale, repository boundaries map to team boundaries, each team owns their deployment pipeline, and the overhead of cross-repository coordination is offset by the organizational clarity and reduced merge conflicts within each team’s codebase.

For small teams where everyone contributes to everything, polyrepos create problems that are disproportionate to their benefits. The organizational clarity argument evaporates when the same 8 people work in all 12 repositories. What remains is pure overhead.

Dependency Hell

When your shared utility library lives in its own repository, every consuming service depends on a published version. Changing the library triggers a multi-step process that is slow, error-prone, and demoralizing:

  1. Make the change in the library repo. Write tests. Push. Wait for CI (3 minutes).
  2. Get a code review. Address comments. Push again. Wait for CI again (3 minutes).
  3. Merge. Wait for CI to run on main (3 minutes). Publish a new version (npm version patch && npm publish).
  4. Open a PR in the API repo to update the library version. Run npm update @harbor/shared-lib. Run tests locally to check for breaks. Push. Wait for CI (5 minutes).
  5. Get a code review for the version bump. Merge. Deploy.
  6. Repeat step 4-5 for the frontend repo. And the admin dashboard repo.

For a breaking change in the shared library, this process takes 1-2 full working days when you account for context switching, waiting for CI, and coordinating with teammates who need to review each PR. In a monorepo, the same change takes 30-60 minutes because you change the library and all consumers in the same PR. The TypeScript compiler or the test suite immediately tells you if something broke, right there in the same CI run.

# Polyrepo workflow for updating a shared library
# Step 1: library repo
cd shared-lib
git checkout -b fix-validation
# Make changes, write tests
git push origin fix-validation
# Wait for review, merge, CI, publish
npm version patch && npm publish

# Step 2: API repo (repeat for every consumer)
cd ../api
git checkout -b update-shared-lib
npm update @harbor/shared-lib
npm test  # Fix any breaks
git push origin update-shared-lib
# Wait for review, merge, deploy

# Step 3: Frontend repo (repeat again)
cd ../frontend
git checkout -b update-shared-lib
npm update @harbor/shared-lib
npm test  # Fix any breaks
git push origin update-shared-lib
# Wait for review, merge, deploy

# Monorepo workflow: same change, single PR
cd monorepo
git checkout -b fix-validation
# Change packages/shared/src/validation.ts
# Update apps/api/src/handlers.ts (fix the call site)
# Update apps/web/src/forms.ts (fix the call site)
npm test  # All tests run, all breaks caught immediately
git push origin fix-validation
# Single review, single merge, coordinated deployment

Inconsistent Tooling and Configuration Drift

With polyrepos, each repository tends to evolve independently. When one developer upgrades ESLint in the API repo, they do not usually think to upgrade it in the frontend, admin dashboard, and shared library repos. Over six months, your API uses ESLint 7 while your frontend uses ESLint 8 with different rule sets. Your API uses Jest 27 while your frontend switched to Vitest. Your shared library emits CommonJS while your frontend expects ESM. Each repository has slightly different CI configuration, different linting rules, different formatting settings, and different TypeScript strict mode settings.

A monorepo enforces consistency because tooling configuration is shared at the root level. One ESLint config consumed by all packages. One TypeScript version. One test runner. One CI pipeline definition. When you upgrade a tool, you upgrade it everywhere in one commit, and CI immediately tells you if any package has compatibility issues with the new version.

The “Which Version Is Deployed” Problem

With polyrepos, answering the question “what version of the shared library is running in production right now” requires checking the deployed version of each service, reading its lockfile, and finding the shared library version. If different services are running different versions (which they usually are, because version updates are manual), you also need to track which version each service is on and whether the versions are compatible with each other.

In a monorepo, the answer is always “whatever is on the main branch.” All packages are at the same commit. There is no version matrix to track. Compatibility is guaranteed by the test suite.

Monorepo Structure That Scales

Here is how we structured our monorepo. The structure follows a pattern that Turborepo, Nx, and other monorepo tools expect:

harbor/
  apps/                         # Deployable applications
    api/                        # Express API server
      package.json
      src/
      tsconfig.json
      Dockerfile
    web/                        # Next.js frontend
      package.json
      src/
      tsconfig.json
    admin/                      # React admin dashboard
      package.json
      src/
      tsconfig.json
  packages/                     # Shared libraries (never deployed independently)
    shared/                     # Shared types, utilities, constants
      package.json
      src/
    eslint-config/              # Shared ESLint configuration
      index.js
      package.json
    tsconfig/                   # Shared TypeScript configurations
      base.json
      nextjs.json
      node.json
    database/                   # Prisma schema and generated client
      schema.prisma
      package.json
    ui/                         # Shared React components
      package.json
      src/
  infrastructure/               # Not a Node.js package
    terraform/                  # IaC (Terraform)
    docker/                     # Shared Dockerfiles and compose files
    k8s/                        # Kubernetes manifests
  turbo.json                    # Turborepo pipeline configuration
  package.json                  # Root package.json (workspaces definition)
  pnpm-workspace.yaml           # pnpm workspace configuration
  .eslintrc.js                  # Root ESLint config
  tsconfig.json                 # Root TypeScript config

Key organizational principles:

  • apps/ contains deployable applications. Each has its own package.json, build configuration, Dockerfile, and deployment pipeline. An app is something that runs: a server, a web application, a CLI tool.
  • packages/ contains shared libraries. These are never deployed independently; they are consumed by apps via workspace references ("@harbor/shared": "workspace:*" in package.json). A package is something that is imported: types, utilities, configurations, database clients.
  • infrastructure/ contains IaC and deployment configuration. This is not a Node.js package and is not part of the workspace dependency graph. It lives in the monorepo for co-location (so infrastructure changes can be reviewed alongside the code changes that require them) but is managed independently.

Turborepo: The Engine That Makes It Work

A monorepo without a task runner is just a large repository with slow builds. Turborepo solves two critical problems: build orchestration (running tasks in the correct dependency order) and caching (skipping tasks whose inputs have not changed).

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "build/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

Build orchestration: The dependsOn: ["^build"] syntax means “build my dependencies before building me.” The ^ prefix indicates upstream dependencies in the package graph. When you run turbo run build, Turborepo analyzes the dependency graph and runs builds in the correct topological order, parallelizing where possible. If api and web both depend on shared, Turborepo builds shared first, then builds api and web in parallel because they are independent of each other.

Caching: Turborepo hashes the inputs of each task: source files, dependencies (including transitive dependencies), environment variables, and the task configuration itself. If the hash matches a previous run, the task is skipped entirely and the cached output (the outputs array in the pipeline definition) is restored from cache. This turns a 3-minute full build into a 10-second incremental build when only one package changed.

# First run: builds everything from scratch
$ turbo run build
 Tasks:    5 successful, 5 total
 Cached:    0 cached, 5 total
   shared:build          2.1s
   database:build        1.5s
   api:build             8.3s
   web:build             12.7s
   admin:build           6.2s
 Time:    16.8s

# Second run: nothing changed, everything cached
$ turbo run build
 Tasks:    5 successful, 5 total
 Cached:    5 cached, 5 total
 Time:    0.4s

# Third run: only shared/src/validation.ts changed
$ turbo run build
 Tasks:    5 successful, 5 total
 Cached:    1 cached, 5 total    (database was not affected)
   shared:build          2.1s   (rebuilt - inputs changed)
   api:build             8.3s   (rebuilt - dependency changed)
   web:build             12.7s  (rebuilt - dependency changed)
   admin:build           6.2s   (rebuilt - dependency changed)
   database:build        CACHED (no dependency on shared)
 Time:    16.0s

Remote Caching: The Killer Feature

Turborepo supports remote caching via Vercel or self-hosted backends. This means cache entries are shared across all developers and CI runners. If Developer A builds the shared package locally and the cache is uploaded, Developer B gets a cache hit when they build the same inputs on their machine. In CI, this means most PR builds only rebuild the packages that were actually changed in the PR, because everything else is cached from previous CI runs or developer builds.

Our CI build time dropped from 14 minutes (polyrepo model, building all three services in separate pipelines) to an average of 4.2 minutes (monorepo with remote caching, only rebuilding changed packages). On PRs that only touch one app, CI completes in under 2 minutes because everything except that one app is served from cache.

CI/CD in a Monorepo

The biggest concern teams have about monorepos is CI/CD: “Will not every commit trigger every pipeline?” With Turborepo’s --filter flag and proper CI configuration, only affected packages are built and tested:

# .github/workflows/ci.yml
name: CI
on:
  pull_request:
    branches: [main]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0  # Full history needed for change detection

      - uses: pnpm/action-setup@v2
        with:
          version: 8

      - uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Build, test, lint (only changed packages and their dependents)
        run: turbo run build test lint --filter=...[origin/main]
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

The --filter=...[origin/main] flag tells Turborepo to determine which packages changed since the main branch and run tasks only for those packages and their downstream dependents. If your PR only touches apps/web/, only web is built, tested, and linted. If your PR touches packages/shared/, all packages that depend on shared are also rebuilt and tested. This is the correct behavior: you want to verify that your shared library change does not break any consumer.

The Migration Path

Migrating from polyrepos to a monorepo is a one-time investment. Here is the step-by-step approach we used over two weeks:

  1. Week 1, Day 1-2: Set up the monorepo skeleton. Create the root package.json with workspace configuration, pnpm-workspace.yaml, turbo.json, and the directory structure (apps/, packages/).
  2. Week 1, Day 2-3: Move the shared library first. Copy it into packages/shared/. Update its package.json name to @harbor/shared. Verify it builds independently with turbo run build --filter=@harbor/shared.
  3. Week 1, Day 3-5: Move apps one at a time. Copy each app into apps/. Replace published package dependencies with workspace references ("@harbor/shared": "workspace:*"). Update import paths if necessary. Verify builds and tests pass for each app.
  4. Week 2, Day 1-3: Consolidate tooling. Unify ESLint config into packages/eslint-config/. Unify TypeScript configs into packages/tsconfig/. Set up a single CI pipeline. This is the most time-consuming step but produces the most long-term value.
  5. Week 2, Day 3-5: Stabilize and verify. Run the full test suite. Verify deployment pipelines. Set up remote caching. Archive old repositories (read-only, not deleted, in case you need to reference old git history).

We did not preserve git history from the individual repos. You can use git subtree add to bring history along, but we decided that clean history starting from the monorepo’s creation was more valuable than carrying over 18 months of fragmented polyrepo history. The old repos remain archived and searchable if we ever need to look something up.

When Monorepos Are Wrong

Monorepos are not universally correct. They are the wrong choice when:

  • Your services are in different languages. Turborepo, Nx, and pnpm workspaces assume a JavaScript/TypeScript ecosystem. If your API is in Go, your ML pipeline is in Python, and your frontend is in TypeScript, a monorepo adds the complexity of cohabitation without the dependency management benefits that make monorepos valuable.
  • Your team is larger than 50 engineers. At this scale, a monorepo requires dedicated build infrastructure (Bazel, Buck2, Pants) and a platform team to maintain it. Turborepo and Nx work well up to about 30-50 engineers; beyond that, you start hitting scaling limits in build times, git performance, and CI resource contention.
  • Your services are truly independent. If Service A and Service B share no code, no types, no deployment coordination, and are maintained by completely separate teams, putting them in the same repo adds CI noise and git complexity without any benefit.
  • You deploy at vastly different cadences. If the API deploys multiple times per day and the data pipeline deploys monthly with extensive validation, the monorepo’s single-branch model can create friction where fast-moving teams feel slowed down by shared CI resources.

Developer Experience Improvements We Did Not Expect

Beyond the productivity gains we anticipated, the monorepo produced several developer experience improvements we did not plan for:

Onboarding time dropped from 3 days to 1 day. New developers clone one repository, run pnpm install, and have the entire system ready to develop locally. In the polyrepo world, they needed to clone 5-8 repositories, configure each one, set up inter-repo linking for local development, and understand which repositories communicated with which. The monorepo’s single turbo run dev command starts all services in parallel with hot reload.

Code review quality improved. When a feature touches the API and the frontend, the reviewer sees both changes in a single PR. They can verify that the API response format matches what the frontend expects, that error handling is consistent across the stack, and that types are shared correctly. In the polyrepo world, reviewers saw each half in isolation and had to mentally reconstruct the full picture.

Refactoring became safe. Renaming a function in the shared library? TypeScript’s compiler checks every consumer in the monorepo during the same build. In the polyrepo world, renaming a shared function required: changing the function in the library, publishing a new version, updating every consumer, and hoping you did not miss one. The monorepo’s type system catches every reference instantly.

Search works across the entire codebase. “Where is this API endpoint called from?” In a monorepo, grep -r or your IDE’s global search covers everything: the API definition, the frontend call sites, the admin dashboard, the integration tests. In polyrepos, you need to search each repository separately and mentally merge the results.

Atomic rollbacks. When a deployment causes an issue, you revert one commit that contains both the API change and the frontend change. In polyrepos, you need to revert two commits in two repositories and redeploy both in the correct order. If the API reverts but the frontend does not (or vice versa), you have an inconsistency that might be worse than the original bug.

Conclusion

For small to medium engineering teams (5-30 engineers) building products with shared code across multiple applications, a monorepo managed by Turborepo is the right default choice. It eliminates the coordination overhead of polyrepos, enforces consistency in tooling and configuration, enables atomic cross-cutting changes that would otherwise require multi-repository coordination, and with remote caching, actually produces faster CI builds than the polyrepo alternative.

The migration is a two-week investment. The payoff is measured in months of reclaimed developer time and reduced coordination friction. Every feature that touches multiple packages, every dependency update, every tooling upgrade becomes dramatically simpler. If you are spending time coordinating changes across repositories, that time is a tax on every feature you ship. Eliminate it.

Leave a comment

Explore
Drag