SM

Command Palette

Search for a command to run...

Blog

What a Turborepo Monorepo Taught Me About Code Quality: The File Tree Mattered More Than the Linter

Syed Moinuddin10 min read
Dev ToolsBackend
What a Turborepo Monorepo Taught Me About Code Quality: The File Tree Mattered More Than the Linter

A first-person account of what a real company monorepo taught me: that code quality came from the file structure and a single source of truth for our models, not from lint rules. Where the apps and packages go, where the backend models live, and what I would do differently.

Here is what a real Turborepo monorepo taught me at work: the thing that moved our code quality was not the linter, it was the file structure and a single source of truth for our models. A linter catches formatting and a few footguns; it does nothing about two services drifting out of sync, duplicated types, or a change in one app silently breaking another. The structure did. Splitting the repo into thin apps and thick shared packages, putting our database models in one package that every app imports, and letting the build graph fail when a contract broke: that is what made bad code expensive and good structure compounding. Here is the layout, where the models live, and what I would do differently.

What does a Turborepo monorepo actually look like?

Two directories carry the whole idea. Turborepo's own guidance is to split packages into apps/ for applications and services, and packages/ for everything else, like libraries, tooling, and shared code S1. A widely used refinement adds a third layer: keep apps/ thin (deployment entry points), keep packages/ thick (shared business logic, UI, and models), and put centralized configuration in a tooling or packages/config directory that everything else extends S6.

company-monorepo/
  apps/
    web/            # Next.js frontend
    api/            # backend service (Node or Nest; see note for FastAPI/Go)
  packages/
    db/             # @repo/db: schema, migrations, generated client + types
    ui/             # shared component library
    config/         # shared tsconfig, eslint, tailwind base configs
  turbo.json
  package.json
  pnpm-workspace.yaml

The important mental model: a monorepo is an architecture decision that organizes code, and Turborepo is the performance layer that orchestrates and caches tasks on top of it. They are not competitors, they complete each other S8. Workspaces (pnpm, npm, or yarn) resolve the local package graph so an app importing @repo/ui gets the local source directly, with no publish step in between; Turborepo sits above that and decides what to build in what order, what to cache, and what to skip S5.

Turborepo apps and packages layout with the shared models package highlighted

FIG. 01 · The apps and packages split, with the one package every app depends on: the models.

Why did the structure matter more than the linter?

Because a linter and the structure solve different problems, and only one of them was our actual problem. Shared lint and formatting configs are worth having, and most "code quality" writeups stop there. But formatting was never why our code broke. It broke when two services assumed different shapes for the same object, when a type got copy-pasted and then edited in one place only, or when a change in one app reached production before anyone noticed it broke another.

The structure is what closed those gaps. Because apps consume shared packages from source with no publish step, a change to a shared package is visible to every consumer in the same atomic commit S5. Because the dependency graph is explicit, a contract change that breaks a downstream app fails that app's build instead of shipping. The linter tells you a line is ugly; the structure tells you a change is wrong.

Where do the models live, and why does that fix code quality?

In one package, and that single decision did more for backend code quality than any rule we added. We put the database models in a dedicated package (call it @repo/db) that owns the schema, the migrations, and the generated client and types, and every app imports from it S3. That is the single source of truth: schemas defined once, imported everywhere, so if a column changes, dependent apps flag build-time type errors immediately rather than failing at runtime S11.

// packages/db/prisma/schema.prisma
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
}
// packages/db/src/index.ts
export { PrismaClient } from "../generated/prisma/client";
export const prisma = new PrismaClient();
// apps/web or apps/api: one import, the same types everywhere
import { prisma } from "@repo/db";
 
const user = await prisma.user.findFirst();
One shared models package imported by every app, so a schema change type-checks across the whole repo

FIG. 02 · One schema, defined once and imported everywhere: a column change becomes a build-time type error, not a runtime surprise.

The gotcha that bites every new developer: the generated client has to exist before anything can build. If someone runs dev on an app without generating the client first, they get errors S3. The fix is to wire it into the task graph so it always runs first, keep migration commands uncached because the schema changes often, and list DATABASE_URL in globalEnv so task hashing stays correct S3.

// turbo.json
{
  "$schema": "https://turborepo.dev/schema.json",
  "globalEnv": ["DATABASE_URL"],
  "tasks": {
    "db:generate": { "cache": false },
    "build": {
      "dependsOn": ["^build", "^db:generate"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "dev": {
      "dependsOn": ["^db:generate"],
      "cache": false,
      "persistent": true
    },
    "lint": {},
    "typecheck": { "dependsOn": ["^build"] },
    "test": { "dependsOn": ["^build"] },
    "db:deploy": { "cache": false }
  }
}
Turborepo task graph showing db:generate running before build so the generated client always exists first

FIG. 03 · The task graph: db:generate runs before every build, so the generated client always exists first.

One wrong turn worth naming: we reached for multiple databases with their own clients before understanding that multiple Prisma clients in one monorepo tend to overwrite each other. The sustainable pattern is one database with multiple schemas, not several separate databases wired together with workarounds S10.

If your backend is not JavaScript or TypeScript, this section changes shape. Turborepo will still orchestrate and cache a FastAPI or Go service as a task, but the shared-package type safety is a JS/TS benefit. For a Python or Go backend, generate a typed client from the OpenAPI or schema and treat that generated package as the single source of truth, instead of hand-writing a TS models package.

What file-structure conventions actually earned their place?

A few, and they are boring, which is the point. The root package.json stays private, keeps minimal devDependencies (basically just turbo and repo tooling), and its scripts only delegate to turbo run. No build logic lives at the root S2.

// package.json
{
  "name": "company-monorepo",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test",
    "typecheck": "turbo run typecheck"
  },
  "devDependencies": { "turbo": "latest" }
}

The conventions that held up: shared config packages for tsconfig, eslint, and Tailwind that every app extends, so the rules stay consistent across the repo rather than drifting per app S6S5. Package tasks over root tasks, always, reaching for root tasks only when a package task cannot do the job S2. Domain-driven isolation for packages rather than one junk-drawer utils, which is also why there is no hard package count; teams of 10 to 20 engineers commonly run somewhere around 10 to 25 packages covering UI, logic, database, and config S6. And the trap to avoid: broad barrel files and careless cross-imports that quietly create circular dependencies and slow builds S6.

Three layers: thin apps on top, thick shared packages in the middle, shared config at the base

FIG. 04 · Thin apps, thick packages, shared config: the three layers every app extends.

What did caching change about how we enforce quality?

It made the checks fast enough to actually enforce. A gate you cannot afford to run on every pull request is not a gate. Turborepo's remote cache stores task results so CI never has to do the same work twice S7, and because it understands the dependency graph, changing only the models package rebuilds only the apps that depend on it S11. In practice that means lint, typecheck, and tests can each run as their own parallel CI job after a shared install, with each depending on upstream packages being built first S5.

Changing one package rebuilds only its dependents, with cached tasks skipped

FIG. 05 · Change one package and only its dependents rebuild, cached and incremental.

The honest caveat: build-speed numbers get quoted loosely, including a common "40 to 85 percent faster builds" figure S9. Treat that as a vendor-adjacent claim, not a promise. The real win is not a headline percentage, it is that fast, incremental checks turn code-quality rules from aspirational into enforced.

When is a Turborepo monorepo the wrong call?

When the projects do not actually share much, or when the stack fights the tooling. If your projects evolve independently with minimal shared dependencies, separate repos are fine, and the upfront convention cost buys you little S7. And Turborepo's core strengths are aimed at JS/TS monorepos with interdependent tasks; if your projects span multiple languages with little to share as packages, the sharing benefit shrinks even though task orchestration still works S9. The failure mode of a polyrepo it does solve is real, though: twenty repos and twenty teams tend to grow twenty flavors of tooling and lose all visibility into cross-project breakage S9.

Polyrepo vs monorepo vs monorepo with Turborepo, at a glance

Comparison of polyrepo, workspaces-only monorepo, and monorepo with Turborepo across code sharing, source of truth, and build speed

FIG. 06 · Polyrepo, workspaces alone, and workspaces plus Turborepo, at a glance.

DimensionPolyrepoMonorepo (workspaces only)Monorepo + Turborepo
Code sharingCopy or publish packagesLocal source, no publishLocal source, orchestrated builds
Single source of truth for modelsHard (duplicated or published)Possible (shared package)Shared package, drift caught at build time
Config consistency (ts, eslint)Diverges per repoShared config packagesShared config, enforced across tasks
Build and CI speedIsolated, no cross reuseRebuilds everythingCached, parallel, only affected rebuild
Breaking-change visibilityLow (isolated)Atomic commit shows impactAtomic commit, graph fails affected build
Onboarding "what goes where"Low upfront, drift laterUpfront convention costUpfront cost, pays off at scale
Multi-language (Python, Go) fitFine (separate)Loose orchestrationOrchestrates tasks, type-sharing is JS/TS only
Best forIndependent projectsSmall shared codebaseMany related apps sharing code and models

Checklist: reach for it, or stay polyrepo?

Reach for a Turborepo monorepo if:

  • Multiple apps share real code (UI, types, models, config) and tend to change together.
  • You want one models package every app imports, with drift caught at build time.
  • Your stack is mostly JS/TS, so shared packages buy you type safety across the boundary.
  • CI speed matters and you can wire caching, local or remote.
  • You can pay the upfront "what goes where" convention cost.

Stay polyrepo (or rethink) if:

  • Your projects evolve independently with little shared code.
  • Your services are in different languages and you would not share TS packages anyway.
  • One team owns one app and the monorepo overhead buys nothing yet.
  • You need per-repo access control or independent release cadences a monorepo complicates.

FAQ

  1. Is a monorepo the same as a monolith?

    No. A monolith is a single deployable; a monorepo is one repository that holds many independently deployable apps and packages.

  2. What is the difference between workspaces and Turborepo?

    Workspaces resolve the local package graph so apps import shared packages from source with no publish step; Turborepo sits on top and orchestrates and caches the tasks.

  3. Where should my backend models live?

    In a dedicated package (for example @repo/db) that owns the schema, migrations, and generated client and types, imported by every app so there is one source of truth.

  4. Why do new developers get errors on first run?

    Usually the generated client has not run yet. Make dev and build depend on the generate task so it always runs first.

  5. Should I cache database migrations?

    No. Keep migration and deploy tasks uncached; cache only pure, output-producing tasks.

  6. apps/ vs packages/, what goes where?

    apps/ holds deployable applications and services and stays thin; packages/ holds shared libraries, UI, models, and config and carries the weight.

  7. How many packages is too many?

    There is no hard limit; aim for domain-driven isolation. Teams of 10 to 20 engineers commonly run around 10 to 25 packages.

  8. Turborepo or Nx?

    Turborepo favors flexibility and low orchestration overhead; Nx is more opinionated, with built-in generators and a stricter structure.

  9. Does Turborepo work with a Python or Go backend?

    It can orchestrate and cache their tasks, but the shared-package type safety is JS/TS only. For a FastAPI or Go service, generate a typed client from the OpenAPI or schema instead of a hand-written TS models package.

  10. Can I share one Prisma client across several databases?

    Not cleanly; multiple clients and databases tend to overwrite each other. Prefer one database with multiple schemas over separate databases.

Sources

  1. S1Turborepo, "Structuring a repository" (Turborepo docs), turborepo.dev
  2. S2Vercel, "Turborepo structure best practices" (vercel/turborepo), github.com
  3. S3Prisma, "How to use Prisma ORM with Turborepo" (Prisma docs), prisma.io
  4. S4Pliszko, "Shared database schema with DrizzleORM and Turborepo," pliszko.com
  5. S5Ajeet Chaulagain, "Inside a 3-app Turborepo monorepo: parallelism, caching, and CI that stays fast," ajeetchaulagain.com
  6. S6Daily Dev Post, "The scalable Turborepo folder structure (2026)," dailydevpost.com
  7. S7Strapi, "Turborepo guide (v2.x, tasks, remote caching)," strapi.io
  8. S8Rohit Arya, "Monorepo vs TurboRepo: what is the real difference?" rohitarya18.medium.com
  9. S9Earthly, "Using Turborepo to build your first monorepo," earthly.dev
  10. S10Vercel, "Multiple Prisma clients and databases" (turborepo discussion #3493), github.com
  11. S11DoHost, "Scaling Next.js with Drizzle and PostgreSQL in a Turborepo," dohost.us

Written by

Syed Moinuddin

Full Stack Engineer.

Notes on monorepos, developer tooling, and building things that survive contact with production.

Command Palette

Search for a command to run...