Skip to main content

ADR-0010: Monorepo with pnpm Workspaces

Status

Accepted - 2025-01-26


Context

TVL Platform consists of multiple packages (database, API services, frontend, workers, shared libraries) that need to work together. We need to decide how to organize code:

Business Requirements

  • Enable code sharing (types, utilities, domain logic)
  • Fast development iteration (change shared code, see effects immediately)
  • Atomic commits (changes across multiple packages in single commit)

Technical Requirements

  • Type safety across packages (TypeScript)
  • Consistent dependency versions
  • Efficient CI/CD (only build/test changed packages)
  • Support 10-20 packages initially

Constraints

  • pnpm already selected (ADR-0007)
  • TypeScript 5.3+ (ADR-0005)
  • Small team (2-5 engineers)
  • Must work with Nx build system (ADR-0011)

Decision

Monorepo using pnpm workspaces to manage all packages in a single repository.

Structure

tvl-platform/ (root)
├── packages/ # Shared libraries
│ ├── database/ # Drizzle ORM, migrations
│ ├── shared/ # Shared types, utilities
│ ├── events/ # Domain events
│ └── validation/ # Zod schemas

├── services/ # Backend services
│ ├── api/ # Fastify API server
│ ├── workers/ # Background workers (BullMQ)
│ └── connectors/ # Channel integrations

├── apps/ # Frontend applications
│ └── web/ # Next.js web app

└── integrations/ # Third-party integrations
├── hostaway/ # Hostaway connector
├── stripe/ # Stripe payment integration
└── sendgrid/ # Email service integration

Workspace Configuration

# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'services/*'
- 'apps/*'
- 'integrations/*'

Rationale

  1. Code Sharing: Shared packages (types, utils, events) used by all services
  2. Atomic Changes: Single commit updates API + frontend + database
  3. Type Safety: TypeScript enforces contracts across packages
  4. DRY Principle: Avoid duplicating code across repositories
  5. Simplified Dependencies: One lock file, consistent versions

Alternatives Considered

Alternative 1: Polyrepo (Multiple Repositories)

Rejected

Pros:

  • Independent deployment (each repo deployed separately)
  • Clear ownership (teams own repositories)
  • No monorepo tooling needed

Cons:

  • Code Duplication: Types, utilities copied across repos
  • Breaking Changes: Hard to coordinate changes across repos
  • Version Hell: Different repos use different versions of dependencies
  • Slow Development: PRs across repos for single feature
  • Complex CI/CD: Coordinate deployments across repos

Decision: Monorepo simplifies development for small team.


Alternative 2: Lerna Monorepo

Rejected

Pros:

  • Popular monorepo tool
  • Workspace support
  • Versioning and publishing support

Cons:

  • Slow: Lerna is slower than native pnpm workspaces
  • Overhead: Adds complexity (unnecessary for our use case)
  • Maintenance: Lerna maintenance has slowed (community concerns)
  • Redundant: pnpm workspaces provide same functionality

Decision: pnpm workspaces simpler and faster.


Alternative 3: Nx Monorepo (without pnpm workspaces)

Rejected

Pros:

  • Excellent build caching
  • Dependency graph visualization
  • Affected detection (only test changed packages)

Cons:

  • Redundant: Nx works WITH pnpm workspaces (not instead of)
  • We use both: pnpm for package management, Nx for build orchestration

Decision: Use pnpm workspaces + Nx together (ADR-0011).


Consequences

Positive

  1. Code Sharing

    • Shared types across frontend and backend
    • Shared utilities (date formatting, validation)
    • Domain events consumed by multiple services
  2. Atomic Changes

    • Single PR updates API, frontend, and database
    • No coordination across repos
    • Example: Add field to Booking type → Update API + frontend in one commit
  3. Type Safety

    • TypeScript enforces contracts
    • Change shared type → Compiler errors everywhere (good!)
    • Refactoring is safe (IDE renames across packages)
  4. Simplified CI/CD

    • One pipeline for all packages
    • Nx caches builds (only rebuild changed packages)
    • Consistent test/lint/build commands
  5. Developer Experience

    • Clone one repo, get entire platform
    • Fast iteration (hot reload across packages)
    • Autocomplete works across packages (TypeScript)

Negative

  1. Repository Size

    • Single large repository (vs. many small repos)
    • Mitigation: Git handles large repos well, use sparse checkout if needed
  2. CI/CD Complexity

    • Must test all packages (or use affected detection)
    • Mitigation: Nx provides affected detection (only test changed)
  3. Merge Conflicts

    • Multiple teams working in same repo
    • Mitigation: Clear domain boundaries reduce conflicts
  4. Deployment Coupling

    • All packages versioned together
    • Mitigation: Independent deployment possible later (Docker containers)

Package Dependencies

Internal Dependencies

// services/api/package.json
{
"name": "@tvl/api",
"dependencies": {
"@tvl/database": "workspace:*",
"@tvl/shared": "workspace:*",
"@tvl/events": "workspace:*",
"@tvl/validation": "workspace:*"
}
}

workspace:* means "use local version" (pnpm resolves to local packages).

Dependency Graph

apps/web

services/api

packages/database
packages/shared
packages/events
packages/validation

Rule: Packages can depend on packages below them (acyclic).


Common Commands

Install Dependencies

# Install all packages
pnpm install

Add Dependency

# Add to specific package
pnpm --filter @tvl/api add fastify

# Add to workspace root
pnpm add -w typescript

Run Commands

# Run in all packages
pnpm -r build
pnpm -r test
pnpm -r lint

# Run in specific package
pnpm --filter @tvl/api dev
pnpm --filter @tvl/database migrate

Build Affected Packages (Nx)

# Only build packages affected by changes
nx affected:build

# Only test affected packages
nx affected:test

Package Naming Convention

Format: @tvl/{package-name}

{
"name": "@tvl/database",
"name": "@tvl/api",
"name": "@tvl/web",
"name": "@tvl/shared"
}

Scoped packages (@tvl/) prevent naming conflicts with npm packages.


Publishing Strategy

Internal Packages (Never Published)

  • @tvl/database
  • @tvl/api
  • @tvl/web
  • All services and apps

Reason: Internal use only, not npm packages.

Shared Libraries (Private npm - Future)

  • @tvl/shared
  • @tvl/events
  • @tvl/validation

If needed: Publish to private npm registry (GitHub Packages or Verdaccio).


Migration Path

Phase 1: Monorepo (MVP.0 - Current)

  • All packages in single repo
  • pnpm workspaces
  • Nx for build caching
  • Deployed as monolith

Phase 2: Independent Deployment (V1.0 - Future)

  • Packages still in monorepo
  • Deploy as separate Docker containers
  • API + Workers deployed independently
  • Frontend deployed to Vercel

Phase 3: Microservices (V2.0+ - If Needed)

  • Extract services to separate repos
  • Shared packages published to private npm
  • Independent teams own services
  • API gateway for routing

Trigger: Team > 15 engineers, clear service boundaries.


Validation Checklist

  • pnpm-workspace.yaml configured
  • All packages use @tvl/* namespace
  • Internal dependencies use workspace:*
  • Nx configured for build caching
  • TypeScript path aliases configured
  • CI/CD builds all packages
  • Affected detection enabled (Nx)
  • Dependency graph is acyclic

References