Core Patterns
Applying screaming architecture in a monorepo means the domain-first naming moves from src/ subdirectories to top-level workspace packages. Each domain becomes a buildable package with explicit dependency declarations.
Turborepo: Domain-First Package Structure
In Turborepo, the workspace mirrors the domain. Package names use the domain noun, not a technical role.
### Turborepo workspace layout
monorepo/
├── packages/
│ ├── users/ # domain package — user management
│ │ ├── package.json # name: "@acme/users"
│ │ ├── src/
│ │ │ ├── user.entity.ts
│ │ │ ├── user.repository.ts
│ │ │ ├── user.service.ts
│ │ │ └── index.ts # public API — export only what others need
│ │ └── tsconfig.json
│ ├── orders/ # domain package — order processing
│ │ ├── package.json # name: "@acme/orders"
│ │ ├── src/
│ │ │ ├── order.aggregate.ts
│ │ │ ├── order.repository.ts
│ │ │ ├── order.service.ts
│ │ │ └── index.ts
│ │ └── tsconfig.json
│ ├── payments/ # domain package — payment processing
│ │ ├── package.json # name: "@acme/payments"
│ │ └── src/
│ │ ├── payment.entity.ts
│ │ ├── payment.service.ts
│ │ └── index.ts
│ └── shared/ # shared infrastructure (NOT a domain concept)
│ ├── database/
│ │ ├── package.json # name: "@acme/database"
│ │ └── src/
│ │ ├── client.ts
│ │ └── index.ts
│ ├── logger/
│ │ ├── package.json # name: "@acme/logger"
│ │ └── src/
│ │ └── index.ts
│ └── config/
│ ├── package.json # name: "@acme/config"
│ └── src/
│ └── index.ts
├── apps/
│ ├── api/ # Express/Fastify app — framework at the edge
│ │ ├── package.json # name: "@acme/api"
│ │ └── src/
│ │ ├── main.ts
│ │ └── app.ts
│ └── web/ # React frontend
│ ├── package.json # name: "@acme/web"
│ └── src/
│ └── features/ # domain-first within the frontend too
│ ├── orders/
│ └── users/
├── turbo.json
└── package.json # workspaces: ["packages/**", "apps/**"]
Turborepo turbo.json pipeline configuration:
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["^build"],
"outputs": []
},
"lint": {
"outputs": []
}
}
}
The ^build dependency means Turborepo builds domain packages before the apps that consume them, respecting the dependency graph automatically.
Nx: Domain Boundaries via Project Tags
Nx adds explicit boundary enforcement through tags and lint rules. Tag each project with its domain and layer.
// packages/orders/project.json
{
"name": "orders",
"tags": ["domain:orders", "type:domain"],
"targets": {
"build": { "executor": "@nx/js:tsc" },
"test": { "executor": "@nx/jest:jest" }
}
}
// packages/shared/database/project.json
{
"name": "database",
"tags": ["domain:shared", "type:infrastructure"],
"targets": {
"build": { "executor": "@nx/js:tsc" }
}
}
// apps/api/project.json
{
"name": "api",
"tags": ["domain:app", "type:application"],
"targets": {
"build": { "executor": "@nx/node:build" }
}
}
Enforce boundaries in .eslintrc.json:
{
"rules": {
"@nx/enforce-module-boundaries": ["error", {
"depConstraints": [
{
"sourceTag": "type:domain",
"onlyDependOnLibsWithTags": ["type:domain", "type:infrastructure"]
},
{
"sourceTag": "type:application",
"onlyDependOnLibsWithTags": ["type:domain", "type:infrastructure"]
},
{
"sourceTag": "domain:orders",
"notAllowedPackageTags": ["domain:payments"]
}
]
}]
}
}
This makes illegal cross-domain imports a lint error. The orders domain cannot import from payments — they must communicate through an application-layer service or a shared event contract.
Shared Library vs Domain Package
The decision boundary: does the code know about a specific business entity?
Decision: shared library or domain package?
Code knows about Order, User, Payment, etc.
→ domain package (packages/orders/, packages/users/)
Code is infrastructure with no domain knowledge
→ shared library (packages/shared/database/, packages/shared/logger/)
Code is a generic TypeScript utility (format dates, parse UUIDs)
→ shared library (packages/shared/utils/)
Code is a UI design system (Button, Input, Modal)
→ shared library (packages/shared/ui/)
Code is a domain EVENT or CONTRACT between two domains
→ shared domain contract package (packages/contracts/order-events/)
— import only the contract, not the full domain package
Package package.json dependency declarations make the rule explicit:
// packages/orders/package.json — orders CAN use database infrastructure
{
"name": "@acme/orders",
"dependencies": {
"@acme/database": "workspace:*",
"@acme/logger": "workspace:*"
}
}
// packages/orders/package.json — orders must NOT import from payments
// If this appeared, it would be a boundary violation:
// "@acme/payments": "workspace:*" ← WRONG: cross-domain direct import
When two domains need to share a concept (e.g., Money, Address, CustomerId), create a contracts package:
// packages/contracts/package.json
{
"name": "@acme/contracts",
"description": "Shared value objects and event schemas — no business logic"
}
Dependency Graph Visualization
Both Turborepo and Nx include dependency graph tools. Run these to visualize domain boundaries.
# Turborepo: generate dependency graph
npx turbo run build --graph
# Turborepo: view in browser (requires @turbo/gen)
npx turbo run build --graph=graph.html && open graph.html
# Nx: interactive dependency graph in browser
npx nx graph
# Nx: show dependencies for a single project
npx nx show project orders --web
A healthy domain-first monorepo graph has a clear shape:
### Healthy dependency graph (no cycles, clear direction)
[api] [web]
| |
[orders] [users] [payments]
\ | /
\ [contracts] /
\ | /
+---[database]--------+
|
[logger]
[config]
Warning signs in the graph:
- Cycles between domain packages (
orders→payments→orders): extract tocontracts - A domain package importing from an
apps/package: flip the dependency direction - Everything importing from a single package with 40+ files: it has become a dumping ground, split it by domain