Monorepos with TurboRepo: Clean Architecture for Full-Stack Apps

Team 4 min read

#turbo

#monorepo

#architecture

#fullstack

#typescript

Introduction

Monorepos have become a powerful pattern for scaling teams and simplifying dependency management. When paired with TurboRepo, they enable fast, deterministic builds and clear architectural boundaries across frontend, backend, and shared code. This post explores how to design a monorepo that enforces a clean architecture while leveraging TurboRepo’s powerful caching and task orchestration.

What is a monorepo and why TurboRepo?

  • A monorepo centralizes multiple apps and packages in a single repository, making cross-cutting concerns (types, interfaces, utilities) easy to share.
  • TurboRepo orchestrates tasks across the monorepo, cache results, and avoid repeating work, which speeds up development and CI.
  • Together, they help you enforce boundaries and reduce blast radii when changing code that spans frontend, backend, and domain logic.

Clean Architecture principles in a monorepo

Clean architecture emphasizes separating concerns and enforcing boundaries between:

  • Domain: business rules and enterprise logic (pure, framework-agnostic)
  • Use Cases / Application: orchestration of domain logic to satisfy user goals
  • Interface Adapters: controllers, presenters, and gateways that translate between domain models and external systems
  • Frameworks and Drivers (Infrastructure): concrete implementations that interact with databases, networks, UI, etc.

In a monorepo, you map these layers to distinct packages or folders and ensure boundaries are respected by import rules and build tooling.

Suggested folder structure

  • apps/
    • web/ (Next.js or other frontend)
    • api/ (REST/GraphQL backend)
  • packages/
    • domain/ (core business models and interfaces)
    • application/ (use cases / services)
    • infrastructure/ (db adapters, external services)
    • adapters/ (API gateways, UI adapters)
    • ui/ (shared UI components)
    • shared/ (types, constants, utilities)

Example tree:

  • apps/
    • web/
    • api/
  • packages/
    • domain/
    • application/
    • infrastructure/
    • adapters/
    • ui/
    • shared/

How to map boundaries to package boundaries

  • Domain: entities, value objects, domain services, and interfaces that define what the app can do.
  • Application (Use Cases): orchestrates domain logic to fulfill a user story; depends on domain but not on infrastructure.
  • Interfaces: controllers, REST/GraphQL adapters, and UI adapters that translate requests into domain actions.
  • Infrastructure: actual persistence, network calls, queues.

By keeping domain and application pure and letting infrastructure be the outer layer, you gain testability and portability across apps.

Configuring TurboRepo for a clean architecture

TurboRepo helps you define a pipeline that respects these boundaries and caches results across the monorepo. A minimal setup might include linting, type checking, tests, building, and a deploy step.

turbo.json (example):

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "lint": {
      "outputs": []
    },
    "typecheck": {
      "dependsOn": ["lint"],
      "outputs": []
    },
    "test": {
      "dependsOn": ["typecheck"]
    },
    "build": {
      "dependsOn": ["test"],
      "outputs": ["dist/**", ".next/**", "build/**"]
    },
    "deploy": {
      "dependsOn": ["build"]
    }
  }
}

What this achieves:

  • Enforces a logical order: lint -> typecheck -> test -> build -> deploy
  • Enables caching across apps and packages, so unchanged parts don’t rebuild
  • Provides a clear boundary for what each app/package can output

Practical patterns to implement

  • Use path aliases to share domain and utilities across apps:
    • tsconfig.json paths:
      • “domain/”: [”../packages/domain/”]
      • “application/”: [”../packages/application/”]
      • “shared/”: [”../packages/shared/”]
  • Enforce clean imports:
    • Domain should not import infrastructure directly
    • Use adapters to translate between domain/use cases and external systems
  • Create a core set of interfaces in domain and application that infrastructure adapters implement
  • Share UI components and design tokens via ui and shared packages to maintain consistency

Sample project layout with boundary names

  • apps/
    • web/ // frontend app (Next.js)
    • api/ // backend API (Express/NestJS)
  • packages/
    • domain/ // entities, value objects, domain services
    • application/ // use cases, application services
    • infrastructure/ // database, HTTP clients, queues
    • adapters/ // controllers, gateways, presenters
    • ui/ // design system and components
    • shared/ // types and utilities

Getting started

  • Create a new monorepo (or adapt an existing one) with TurboRepo:
    • npx create-turbo@latest monorepo-with-turbo
  • Install dependencies with your package manager
    • pnpm install
  • Define your folder structure and adjust tsconfig paths
  • Implement domain and application layers first, then add infrastructure adapters
  • Configure TurboRepo pipelines as shown above and iterate

Tips and common pitfalls

  • Pitfall: Tightly coupling domain to a specific framework or database
    • Avoid importing infrastructure packages into domain or application layers
  • Pitfall: Large, shared barrel files
    • Prefer explicit imports and small, cohesive packages
  • Tip: Use strict linting and a boundaries-focused ESLint plugin to enforce architecture rules
  • Tip: Use Turbo’s remote caching in CI to speed up pipelines across environments

Conclusion

A well-structured monorepo powered by TurboRepo can make clean architecture a practical reality for full-stack apps. By clearly separating domain, application, adapters, and infrastructure, teams gain better testability, faster iteration, and more predictable deployments. Start small with a minimal layout, establish your boundaries, and let TurboRepo handle the orchestration and caching as your repo grows.