Monorepos with TurboRepo: Clean Architecture for Full-Stack Apps
#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/”]
- tsconfig.json paths:
- 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.