Project Structure: Solutions & Projects vs. Monorepos & Workspaces
For .NET engineers who know:
.slnfiles,.csprojfiles, Project References, and MSBuild’s role in assembling multi-project solutions. You’ll learn: How the JS/TS ecosystem maps Solution → Monorepo, Project → Package, Assembly → npm package, and how to navigate an unfamiliar JS/TS codebase from the file system up. Time: 10-15 minutes
The .NET Way (What You Already Know)
In .NET, physical organization and logical organization are both handled by a pair of files: the Solution (.sln) and the Project (.csproj). The solution is the container — it lists which projects belong together and in what order to build them. Each project compiles to a single assembly (.dll or .exe), has its own dependencies defined in the .csproj, and can reference other projects in the same solution via <ProjectReference>.
A typical multi-project solution looks like this:
MyApp.sln
├── src/
│ ├── MyApp.Api/
│ │ ├── MyApp.Api.csproj # references MyApp.Core
│ │ └── Controllers/
│ ├── MyApp.Core/
│ │ ├── MyApp.Core.csproj # no internal references
│ │ ├── Domain/
│ │ └── Services/
│ └── MyApp.Infrastructure/
│ ├── MyApp.Infrastructure.csproj # references MyApp.Core
│ └── Data/
└── tests/
└── MyApp.Tests/
├── MyApp.Tests.csproj # references MyApp.Api, MyApp.Core
└── ...
The .csproj is doing two things at once: it defines how the project compiles (target framework, nullable settings, warnings-as-errors), and it defines what the project depends on (NuGet packages and project references). The SDK-style .csproj makes this reasonably concise.
The .sln ties everything together. Visual Studio reads it to know which projects to load. dotnet build MyApp.sln builds all of them in dependency order. dotnet test MyApp.sln finds all test projects automatically.
This model has two important properties that you should keep in mind as you read the rest of this article: strong isolation (each project is its own assembly, its own namespace root, its own compilation unit) and explicit dependency graph (project references are declared, not implied).
The JS/TS Way
Single-App Projects
Before covering monorepos, it is worth knowing what a standalone JS/TS project looks like, because you will encounter these at least as often as monorepos.
The root-level package.json is the entry point for understanding any JS/TS project — it is the rough equivalent of reading a .csproj file, except it also contains the build scripts, the test runner invocation, and sometimes the project’s entire configuration surface.
A standalone Next.js application:
my-next-app/
├── package.json # dependencies, scripts, name/version
├── package-lock.json # lockfile (or pnpm-lock.yaml if using pnpm)
├── tsconfig.json # TypeScript compiler configuration
├── next.config.ts # Next.js framework configuration
├── .env.example # documented environment variable template
├── .env.local # actual secrets (never committed)
├── src/
│ ├── app/ # App Router: file-system-based routing
│ │ ├── layout.tsx # root layout (like _Layout.cshtml)
│ │ ├── page.tsx # root page (/)
│ │ ├── globals.css
│ │ └── dashboard/
│ │ └── page.tsx # /dashboard route
│ ├── components/ # shared React components
│ ├── lib/ # utility functions, type definitions
│ └── types/ # global TypeScript type declarations
└── public/ # static assets (no build processing)
A standalone NestJS API:
my-nest-api/
├── package.json
├── tsconfig.json
├── tsconfig.build.json # extends tsconfig.json, excludes tests
├── nest-cli.json # NestJS build tooling configuration
├── .env.example
├── src/
│ ├── main.ts # application bootstrap (like Program.cs)
│ ├── app.module.ts # root module (like Startup.cs / IServiceCollection)
│ ├── app.controller.ts
│ ├── app.service.ts
│ └── users/ # feature module (like a domain project)
│ ├── users.module.ts
│ ├── users.controller.ts
│ ├── users.service.ts
│ ├── dto/
│ │ ├── create-user.dto.ts
│ │ └── update-user.dto.ts
│ └── entities/
│ └── user.entity.ts
└── test/
├── app.e2e-spec.ts
└── jest-e2e.json
Note the NestJS layout: the users/ directory is a self-contained feature slice — module, controller, service, DTOs, and entities all in one folder. This is a NestJS convention, not a file-system-enforced rule. It resembles organizing by domain in ASP.NET Core (vertical slice architecture), but there is no compiler telling you about it.
Monorepos
When your project grows to include multiple apps or shared libraries, a monorepo packages them together under a single repository root. This is the direct equivalent of a .sln containing multiple .csproj files.
The structure for a monorepo containing a Next.js frontend, a NestJS API, and a shared types package:
my-project/ # .sln equivalent (the root)
├── package.json # root package.json (workspace definition)
├── pnpm-workspace.yaml # tells pnpm which folders are packages
├── pnpm-lock.yaml # single lockfile for the entire monorepo
├── turbo.json # Turborepo build orchestration config
├── tsconfig.base.json # base TypeScript config, extended by all packages
├── .env.example
├── apps/
│ ├── web/ # Next.js frontend
│ │ ├── package.json # name: "@myproject/web"
│ │ ├── tsconfig.json # extends ../../tsconfig.base.json
│ │ ├── next.config.ts
│ │ └── src/
│ └── api/ # NestJS backend
│ ├── package.json # name: "@myproject/api"
│ ├── tsconfig.json
│ ├── nest-cli.json
│ └── src/
└── packages/
├── types/ # shared TypeScript types
│ ├── package.json # name: "@myproject/types"
│ ├── tsconfig.json
│ └── src/
│ └── index.ts
└── utils/ # shared utility functions
├── package.json # name: "@myproject/utils"
├── tsconfig.json
└── src/
└── index.ts
The root package.json in a pnpm workspace looks like this:
{
"name": "my-project",
"private": true,
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"test": "turbo run test",
"lint": "turbo run lint"
},
"devDependencies": {
"turbo": "^2.0.0",
"typescript": "^5.4.0"
}
}
And pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
This file is roughly equivalent to the project list inside a .sln file. It tells pnpm: these directories are the packages that make up this workspace. Every directory listed here must have its own package.json.
Workspace Dependencies (Project References)
When @myproject/web needs types from @myproject/types, you declare that dependency in apps/web/package.json using the workspace: protocol:
{
"name": "@myproject/web",
"dependencies": {
"@myproject/types": "workspace:*"
}
}
// .NET equivalent in MyApp.Api.csproj:
// <ProjectReference Include="../MyApp.Core/MyApp.Core.csproj" />
The workspace:* syntax tells pnpm: use the local version of this package, not the one from the npm registry. At build time, pnpm symlinks the packages together. No separate publish step is required for internal packages during development.
tsconfig.json: The .csproj for Compilation
tsconfig.json is the TypeScript compiler configuration file. It controls what TypeScript compiles, how, and what rules apply. For .NET engineers, the mental model is: .csproj’s <PropertyGroup> section, but for the TypeScript compiler rather than the C# compiler.
A representative tsconfig.json for a NestJS API:
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}
Mapped to .csproj equivalents:
| tsconfig.json | .csproj / MSBuild equivalent |
|---|---|
"target": "ES2022" | <TargetFramework>net9.0</TargetFramework> |
"strict": true | <Nullable>enable</Nullable> + <TreatWarningsAsErrors>true</TreatWarningsAsErrors> |
"outDir": "./dist" | <OutputPath>bin/Release</OutputPath> |
"rootDir": "./src" | <Compile Include="src\**\*.cs" /> |
"paths" | N/A in .csproj; similar to project references or using aliases |
"exclude" | <Compile Remove="..." /> |
"experimentalDecorators": true | N/A — decorators are not part of C# |
In a monorepo, you typically have a tsconfig.base.json at the root that defines shared settings, then each package’s tsconfig.json extends it and adds package-specific overrides:
// packages/types/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true
},
"include": ["src"]
}
The "declaration": true option generates .d.ts type definition files alongside the compiled JavaScript. This is how TypeScript packages expose their types to consumers — the rough equivalent of having a public API surface that other assemblies can reference.
Build Orchestration with Turborepo
Turborepo (often shortened to “turbo”) is the build orchestrator for JS/TS monorepos. The closest .NET analogy is MSBuild’s dependency graph resolution: turbo understands which packages depend on which, and it builds them in the right order while parallelizing everything it safely can.
The turbo.json configuration:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"test": {
"dependsOn": ["build"]
},
"lint": {}
}
}
The "^build" syntax means “run the build task in all dependencies first.” So if @myproject/web depends on @myproject/types, running turbo build in the root will build types before building web. This is the behavior you get for free from MSBuild with <ProjectReference> — turbo makes it explicit.
Turbo also provides build caching: if nothing in a package has changed since the last build, turbo skips that package’s build entirely and restores outputs from cache. This is similar to incremental compilation in MSBuild, but turbo’s caching is more aggressive and can be distributed across CI runners.
Key Differences
| Concept | .NET | JS/TS |
|---|---|---|
| Workspace container | .sln file | pnpm-workspace.yaml + root package.json |
| Package/project definition | .csproj | package.json in each app/package directory |
| Compilation output | Assembly (.dll) | JavaScript files in dist/ + type definitions (.d.ts) |
| Internal dependency | <ProjectReference> | "@myproject/pkg": "workspace:*" in package.json |
| External dependency | <PackageReference> | Named dependency in package.json dependencies |
| Compilation settings | <PropertyGroup> in .csproj | compilerOptions in tsconfig.json |
| Build orchestration | MSBuild (implicit, via solution) | Turborepo (explicit, via turbo.json) |
| Build cache | MSBuild incremental (per-machine) | Turbo cache (per-machine or remote) |
| Namespace root | Assembly name / rootnamespace | No enforced equivalent; conventions vary |
| Package visibility | public/internal access modifiers | No runtime enforcement; exports field in package.json restricts module resolution |
One difference that often surprises .NET engineers: there is no concept of internal visibility in TypeScript at the package level. You can mark members private or protected on classes, but you cannot prevent another package in your monorepo from importing a module that you intend to be “internal” — unless you carefully configure the exports field in package.json to limit which paths are publicly resolvable.
Gotchas for .NET Engineers
1. node_modules exists in multiple places in a monorepo, and this is intentional.
In a pnpm workspace, each package has its own node_modules directory containing symlinks to the actual package files stored in a central content-addressable store. You will also see a node_modules at the root. Do not try to rationalize this based on your NuGet mental model — NuGet has a single global cache and project-local references; pnpm has a similar global store but surfaces local node_modules per package to satisfy Node.js’s module resolution algorithm. The key rule: never manually modify node_modules. Run pnpm install at the repo root and let pnpm manage the structure.
2. There is no single entry point for “build all projects in the right order” unless you configure it.
In .NET, dotnet build MySolution.sln automatically resolves the dependency graph and builds in order. In a JS/TS monorepo, this is not automatic. Without Turborepo (or a similar orchestrator like Nx), running pnpm build in the root will attempt to build all packages in parallel or alphabetical order — which will fail if @myproject/web depends on the built output of @myproject/types. You need Turborepo’s "dependsOn": ["^build"] configuration to get the correct ordering. Many monorepos that look broken to newcomers are broken for exactly this reason: someone ran a build command outside of turbo.
3. tsconfig.json paths aliases require a separate runtime resolution step.
The paths option in tsconfig.json — commonly used to define import aliases like @/ to map to src/ — is a TypeScript compiler feature only. It tells the TypeScript language server and tsc how to resolve imports, but it does not affect the output JavaScript. If you use import { something } from '@/lib/utils' and your runtime is Node.js, the path alias will not be resolved at runtime unless you configure an additional tool to handle it. For NestJS, this means adding path alias configuration to nest-cli.json or using tsconfig-paths at startup. For Next.js, the framework handles this automatically. For a shared package compiled with tsc, you need tsc-alias or similar. This is a common source of “works in the editor, fails at runtime” bugs.
4. The absence of a .sln-equivalent means project discovery is by convention, not registration.
In .NET, a project must be explicitly added to the solution to be part of the build. In a pnpm workspace, any directory matching the glob patterns in pnpm-workspace.yaml is automatically treated as a workspace package. This means a new packages/my-new-lib/ directory with a package.json is immediately part of the workspace on the next pnpm install. There is no registration step. This is convenient but also means orphaned or experimental packages sitting in the right directory are silently included.
5. package.json "main", "module", and "exports" fields control what is actually public.
When one package in your monorepo imports from another, Node.js resolves what gets imported based on the "exports" field in the target package’s package.json. If you set "exports": { ".": "./dist/index.js" }, then only what is exported from dist/index.js is importable — all other paths in the package are opaque to the consumer. Omit exports, and Node.js allows importing any path inside the package. This is the closest you get to .NET’s internal keyword for package-level boundaries.
Hands-On Exercise
The goal is to orient yourself in an unfamiliar monorepo — the same skill you use when joining a team with an existing .sln.
Clone any public monorepo (or use the one you are working in) and complete this walkthrough:
Step 1: Find and read the workspace definition.
cat pnpm-workspace.yaml
# or, if using npm workspaces:
cat package.json | grep -A 10 '"workspaces"'
This tells you how many packages exist and where they live.
Step 2: List all packages and their names.
# Lists every package.json in the workspace, excluding node_modules
find . -name "package.json" \
-not -path "*/node_modules/*" \
-not -path "*/.next/*" \
-not -path "*/dist/*" \
| sort
For each package.json found, read the "name" field. This is your project’s assembly list.
Step 3: Map the dependency graph.
For each package, check what workspace packages it depends on:
# In the web app's directory:
cat apps/web/package.json | grep "workspace:"
Draw or mentally map the dependency graph. This is the equivalent of reading <ProjectReference> entries.
Step 4: Find the entry points.
For each package, locate:
- The
"main"or"exports"field inpackage.json— this is the public API - The
"scripts"field — this tells you how to run, build, and test the package
cat apps/api/package.json | grep -A 10 '"scripts"'
Step 5: Read the tsconfig.json files.
Start with the root tsconfig.base.json if it exists, then read each package’s tsconfig.json to understand its compilation targets, path aliases, and any special settings.
Step 6: Read turbo.json.
Understand the task dependency chain. Answer: what runs before what? What is cached? What always reruns?
After completing these six steps, you should be able to answer: how many packages are in this monorepo, what does each produce, what depends on what, and how do you build and run the whole system.
Quick Reference
| You want to… | .NET command | JS/TS command |
|---|---|---|
| Create a new solution | dotnet new sln -n MyApp | mkdir my-app && cd my-app && pnpm init |
| Add a project to solution | dotnet sln add ./src/MyApp.Api | Add directory to pnpm-workspace.yaml glob |
| Add a NuGet package | dotnet add package Newtonsoft.Json | pnpm add zod --filter @myproject/api |
| Add a project reference | Edit .csproj: <ProjectReference ...> | Edit package.json: "@myproject/types": "workspace:*" |
| Build all projects | dotnet build MySolution.sln | pnpm turbo build (from root) |
| Run a specific project | dotnet run --project src/MyApp.Api | pnpm --filter @myproject/api dev |
| Run all tests | dotnet test MySolution.sln | pnpm turbo test |
| Restore dependencies | dotnet restore | pnpm install (from root) |
| List all projects | Visual Studio Solution Explorer | find . -name "package.json" -not -path "*/node_modules/*" |
| .NET concept | JS/TS equivalent |
|---|---|
.sln file | pnpm-workspace.yaml + root package.json |
.csproj file | package.json (per package) |
<PropertyGroup> | tsconfig.json compilerOptions |
<PackageReference> | dependencies / devDependencies in package.json |
<ProjectReference> | "@scope/package": "workspace:*" |
Assembly (.dll) | dist/ directory + .d.ts type definitions |
internal access modifier | exports field in package.json (path restriction) |
| MSBuild dependency graph | turbo.json "dependsOn": ["^build"] |
| MSBuild incremental compile | Turborepo task caching |
dotnet build | turbo build |
dotnet test | turbo test |
dotnet restore | pnpm install |
Solution-level global.json | Root package.json + .nvmrc or .node-version |
Further Reading
- pnpm Workspaces — Official Documentation — The definitive reference for workspace configuration, the
workspace:protocol, and filtering. - Turborepo Core Concepts — Explains task pipelines, caching, and the dependency graph that replaces MSBuild’s implicit ordering.
- TypeScript Project References — The TypeScript compiler’s native multi-project support. Less common in the monorepo ecosystem than Turborepo, but worth knowing, especially for library authors.
- tsconfig.json Reference — Complete reference for every
compilerOptionsfield. Bookmark this; you will use it when debugging type errors that seem to disappear or appear based on which directory you runtscfrom.