Google keeps most of its code in a single repository holding billions of lines and thousands of services, and a change that touches many of them lands as one commit reviewed in one place. Most teams never reach that scale, but they can borrow the shape, since a monorepo holds many independently deployed services in one repository regardless of size. Interest in the pattern has climbed over the last few years as teams that split into microservices found the polyrepo sprawl, one repository per service, harder to change than the services themselves.
A monorepo describes where code lives, and it says nothing about how the system runs, so you can hold a dozen services in one repository and still deploy each on its own. What decides whether that works is how you lay the repository out, how you share code between services without quietly welding them together, and whether a monorepo is the right call over a repository per service.
A monorepo layout: a services directory holding several independent services, and a packages directory holding shared internal packages that the services import.
Most monorepos settle into two top-level directories, one for services and one for shared packages. Each service owns its directory completely, including its handlers, its database migrations, and its deploy configuration, so a person or an agent working on orders has everything that service needs in one place. Shared code lives under packages, split into small units by concern rather than one grab-bag utility package that every service ends up depending on.
repo/
services/
orders/ # handlers, migrations, deploy config
users/
billing/
packages/
types/ # shared domain types
auth/ # auth helpers
db-client/ # thin database wrapper
package.json # workspace root
turbo.json # or nx.json, for build orchestration
The workspace root ties it together. In a TypeScript repository that is npm, pnpm, or Yarn workspaces declaring where the packages live, usually paired with a build tool like Turborepo or Nx that understands the dependency graph between services and packages. In a Go repository it is a single module or a Go workspace with go.work. Either way the tooling knows that orders imports types, so when types changes it knows orders needs rebuilding and users might not.
The reason to keep services in one repository is that sharing becomes trivial, and that is also the trap. A shared package is imported by every service that depends on it, so a change to that package is a change to all of them at once. This is fine when the package is small and its interface is stable, and it turns into distributed coupling when the package grows into a shared core that half the services reach into.
A few rules keep the sharing honest:
orders needs something from users, it should go through the interface users publishes, not reach into its handlers or its database. A direct import across service internals is the coupling microservices exist to prevent, and a monorepo makes it one autocomplete away, so it needs a rule.What these rules protect is the difference between coupling you can see and coupling you cannot, because an explicit import through a published interface shows up in review and gets checked by the compiler, while a shared mutable data model or a deep reach into another service's guts stays hidden until a change in one place breaks something in another. A monorepo makes that hidden coupling easy to create by accident, so the rules exist to keep the dependencies you take on visible.
Sharing a repository does not mean sharing a deploy, and getting this wrong is how teams end up with the worst of both worlds. If every commit rebuilds and redeploys every service, you have a monolith release cadence with microservice operational cost, which is the distributed monolith under a different name.
The setup that works detects which services a commit touched and acts only on those. Build tools like Turborepo, Nx, and Bazel model the dependency graph, so a change to the orders directory rebuilds orders, a change to the shared types package rebuilds every service that imports it, and a change to a service nobody depends on rebuilds nothing else. CI runs the affected tests and deploys the affected services, and the rest stay untouched.
A monorepo CI pipeline: one commit is analyzed against the dependency graph to find affected services, then only those services are built, tested, and deployed independently.
The payoff is a change that crosses services, say adding a field that orders sends and billing reads, arriving as one commit, one review, and one atomic merge. In a polyrepo that same change is two pull requests in two repositories that have to merge and deploy in the right order, and the window between them is where things break. The cost is that CI has to be graph-aware from early on, because a naive pipeline that runs everything on every commit gets slow and expensive fast as the repository grows.
The choice is mostly about who owns what and how often changes cross service lines.
| Dimension | Monorepo | Polyrepo |
|---|---|---|
| Cross-service change | One commit, one review | Coordinated across repos |
| Code sharing | Direct import of internal packages | Published versioned packages |
| Team isolation | Softer, enforced by convention | Hard, enforced by repo boundaries |
| Access control | Usually repo-wide | Per repository |
| Release cadence | Independent, if CI is graph-aware | Independent by default |
| Tooling investment | Higher up front (build graph, CI) | Lower to start, sprawls later |
| Best fit | One team or a few, changes cross services | Independent teams, strict ownership |
A monorepo fits when a single team or a handful of them own most of the services and changes routinely cross boundaries, because keeping everything in one place makes those changes cheap to make and easy to review together. It also gives an AI coding agent the whole system to read at once, which matters more as agents write more of the code and reason better over a codebase they can see end to end than across repositories they cannot.
A polyrepo fits when services are owned by independent teams that need hard isolation, their own release schedule, and access control scoped per service, since a repository boundary enforces all three without relying on convention. The honest tradeoff is that a monorepo trades enforced isolation for cheap coordination, so it asks for discipline and tooling in exchange for making cross-service work easy. For most teams below the scale where separate teams truly own separate services, the monorepo is the better starting point, and a service can always be split into its own repository later when a team needs to own it end to end.
A monorepo takes work to assemble: a workspace, a build tool that understands the dependency graph, a CI pipeline that deploys only affected services, and the discipline to keep shared code from turning into hidden coupling. An Encore app arrives as a monorepo by construction. You define several services in one codebase, and they call each other through the ~encore/clients import as ordinary typed functions, so the shared contract between services is the function signature itself, checked at compile time.
// orders/orders.ts
import { api } from "encore.dev/api";
import { users } from "~encore/clients";
export const create = api(
{ method: "POST", path: "/orders", expose: true },
async (p: CreateParams): Promise<Order> => {
// A call into another service in the same repo,
// type-checked at compile time. No shared table,
// no reaching into the users service internals.
const user = await users.get({ id: p.userId });
return placeOrder(user, p.quantity);
},
);
Because the calls are type-checked, a breaking change to a service's interface surfaces across every caller in the repository at compile time rather than in production, which is the exact coupling that a hand-built monorepo has to guard against by convention. Each service still gets its own database and infrastructure, declared in code and provisioned into your own AWS or GCP account, and services deploy together from the one codebase. The TypeScript walkthrough builds a multi-service backend this way from scratch, and the monolith vs microservices guide covers the broader tradeoff a monorepo layout sits inside.
Yes, and many teams run dozens of services from one repository. Each service keeps its own directory, deploy target, and often its own database, while shared types and utilities live in common packages the services import. Google, Meta, and Uber run large service estates this way. The monorepo holds the code together so changes across services land in one commit and one review, but each service still deploys on its own.
Put shared code in versioned internal packages that services import, such as a package for common types, one for auth helpers, and one for a database client. Keep those packages small and stable, since anything they touch becomes coupling across every service that imports them. Avoid reaching directly into another service's internal files. Import through its published interface so the boundary stays visible and the two can still deploy separately.
It depends on team structure. A monorepo fits when a small or mid-sized team owns most services and changes often cross service boundaries, since one commit and one review cover the whole change. Separate repositories fit when independent teams need hard isolation, their own release cadence, and strict access control per service. Start with a monorepo and split a service out only when a team needs to own it end to end.
No. A monorepo is about where code lives, not how it ships. Services in a monorepo still build and deploy independently. A good CI setup detects which services a commit changed and rebuilds only those, so touching one service does not redeploy the rest. Sharing a repository does not force sharing a release, and treating it as one deploy recreates the distributed monolith you were trying to avoid.
A monolith is one deployable application that runs as a single unit. A monorepo is one repository that can hold many independently deployable services. You can have a monolith in its own repo, or many microservices in a single monorepo. The monorepo describes source layout; the monolith describes runtime shape. Confusing the two leads teams to think one repository means one process, which is not the case.
Keep shared packages minimal and treat them as public interfaces with a stable contract. Do not let services import each other's internal files, and be cautious about a shared data model that many services write to, since that quietly binds their schemas together. Some frameworks make the boundary type-checked, so a breaking change to a shared type surfaces at compile time across every caller rather than in production.