How to Structure a Monorepo for a Growing SaaS Backend
The moment a SaaS team splits its second service out of a monolith, someone will suggest a monorepo. The idea sounds clean: one repository, shared tooling, atomic commits across service boundaries. What nobody warns you about is that the front-end ecosystem — Nx, Turborepo, workspaces — has shaped almost every monorepo guide published in the last three years. Back-end teams inherit those mental models and then spend months unwinding the parts that do not translate.
This article is specifically for teams building and scaling SaaS backends: multiple services, shared business logic, independent deployment pipelines, and databases that have to evolve without downtime.
Why the Front-End Monorepo Model Falls Short for Backends
Front-end monorepos optimize for build caching and bundle output. The fundamental unit is a package that compiles into an artifact consumed by a browser. Shared code is largely stateless — utility functions, UI components, design tokens.
Back-end services carry entirely different concerns:
- State: each service may own one or more databases, message queues, or caches.
- Runtime isolation: a memory leak or a runaway goroutine in one service must not destabilize another.
- Deployment granularity: you ship
billing-serviceindependently ofnotification-service, and rollbacks need to be surgical. - Secret and config management: environment variables are not just
API_URL; they include credentials, feature flags, and per-environment connection strings.
Importing a front-end monorepo template and bolting services onto it treats these concerns as afterthoughts. Structure the repo around your back-end realities from day one.
A Practical Directory Layout
There is no universally correct structure, but the following layout has held up well for SaaS backends with five to thirty services:
/
├── services/
│ ├── auth/
│ ├── billing/
│ ├── notifications/
│ └── api-gateway/
├── packages/
│ ├── db-client/
│ ├── event-bus/
│ ├── logger/
│ └── schema-types/
├── infra/
│ ├── terraform/
│ └── kubernetes/
├── migrations/
│ ├── auth/
│ └── billing/
└── tools/
├── scripts/
└── ci/
Key decisions baked into this layout:
services/— each directory is an independently deployable unit with its ownDockerfile, dependency manifest, and CI configuration.packages/— shared internal libraries. These are versioned and consumed by services, but they are not published to a public registry.migrations/— database migrations live at the repo root, scoped by service. More on this below.infra/— infrastructure-as-code sits alongside application code. Changes to a Terraform module can be reviewed in the same pull request as the service that depends on it.
Defining Service Boundaries Before You Write Code
The hardest monorepo problems are not tooling problems — they are architecture problems that tooling makes visible. If two services share a database table, they are not two services; they are one service with two entry points. No amount of directory structure fixes that.
Before organizing your repo, define service boundaries by asking:
- What is the unit of independent deployment for this capability?
- Can this service be taken offline without affecting others beyond a graceful degradation?
- Does this service own its data, or does it read from another service's store?
When boundaries are clear, the monorepo structure follows naturally. When they are blurry, you will end up with circular imports between packages/ and services/, which is the single most reliable sign that your service decomposition needs revisiting.
Shared Libraries: The Right Things to Share
Shared libraries (packages/) are the primary value proposition of a back-end monorepo. But sharing the wrong things creates the same tight coupling you were trying to escape from a monolith.
Good candidates for shared packages:
- Logging and observability wrappers (structured log format, trace ID injection)
- Event schema definitions and serialization (if you use an event bus)
- Internal HTTP client factories with retry, timeout, and circuit-breaker defaults
- Database client configuration and connection pooling helpers
- Common validation logic tied to your domain (e.g., phone number or currency formatting for a Ghanaian market product)
Avoid sharing:
- Business logic that belongs to a single service domain
- ORM models or entity definitions — these couple services to each other's data layer
- Anything that embeds environment-specific configuration
A useful rule: if changing a shared package requires you to update and redeploy more than two services simultaneously, the abstraction is too broad.
Database Migrations at Scale
This is where most back-end monorepo guides go silent. Migrations need special treatment because they are stateful, irreversible in production, and often tied to a specific service's deployment sequence.
Recommendations that hold up under pressure:
- Scope migrations to the service that owns the schema.
migrations/billing/contains only billing schema changes. No cross-service migration files. - Run migrations as a pre-deploy step in CI, not at application startup. Application startup migration runs create race conditions when you have multiple instances deploying simultaneously.
- Enforce backward-compatible migrations during any deployment window. Add columns before the code that reads them ships; drop columns only after the code that wrote them is fully retired.
- Version your migration runner separately from your application image. This makes it possible to run a migration dry-run in staging without touching the application container.
Dependency Management Pitfalls That Surface at Scale
A monorepo with a single root-level package.json (or go.mod, or pyproject.toml) is a monorepo that will eventually create problems. Common pitfalls:
- Version lock conflicts: Service A needs
express@4.x, Service B has moved toexpress@5.x. A single lockfile cannot accommodate both safely. - Phantom dependencies: Service A accidentally imports a package installed by Service B's dependency chain. It works locally, breaks in the Docker build, and the error message is cryptic.
- Build time bloat: Installing every dependency in the repo to build a single service defeats the purpose of independent deployability.
The practical fix is per-service dependency manifests with a shared root manifest reserved only for dev tooling (linters, formatters, commit hooks). Each service installs its own dependencies during its own Docker build stage. Shared packages/ are symlinked or referenced by path — never installed from a registry.
CI/CD: Build Only What Changed
The operational payoff of a well-structured monorepo is path-based CI triggers. A change to services/billing/ should trigger only the billing service pipeline. A change to packages/event-bus/ should trigger the pipelines for every service that depends on it — and nothing else.
Tools like GitHub Actions with path filters, or dedicated monorepo CI orchestrators, can compute these dependency graphs automatically — but only if your directory structure makes the dependency relationships explicit. This is another reason loose coupling between services and shared packages matters: the CI graph mirrors the architectural graph.
Why This Matters for Your Project
If you are building a SaaS product that needs to grow from one service to ten without a full re-platforming exercise, the structural decisions you make in the first few months are disproportionately expensive to undo later. A back-end-first monorepo strategy — clear service boundaries, disciplined shared libraries, isolated migrations, and per-service dependency management — gives your engineering team the speed of a monolith during early development and the operational control of microservices as you scale. Getting the structure right early is one of the highest-leverage investments a SaaS engineering team can make.




