All docs/Monorepo Patterns

docs/architecture/monorepo-architecture-feature-oriented-packages.md

Monorepo Architecture — Feature-Oriented Packages with Runtime Entrypoints

This document defines how we organize code in this monorepo around business features, while keeping clear runtime boundaries and a stable developer experience. It complements the restructuring direction in docs/features/20250901-monorepo-restructuring-shared-web-cloud-prd.md and focuses on practical, durable principles for day-to-day work.

Principles

  • Organize by feature (domain), not by tech: Packages map to business capabilities (video, auth, editing, branded-flow, design-system, core).
  • Keep apps thin: Routes, layouts, server actions, and minimal glue live in apps/*; the heavy logic and reusable UI live in packages/*.
  • One package per feature: Prefer sub-areas inside a package (server, web, types) instead of spawning micro-packages.
  • Clear runtime boundaries: Each feature exposes distinct entrypoints for server, web, and types; apps import by intent.
  • Own your types: Types live with the feature (e.g., @repo/video/types). No central dumping ground.
  • Stable DX: Imports are ergonomic and consistent across apps; internal details remain private to each package.
  • Split only when lifecycle diverges: If something deploys or versions independently (e.g., a worker), give it its own app/package.

Proposed Structure (no tech details)

  • Apps

    • branded-video-flow — Thin Next.js shell; consumes branded-flow + branded-flow-editor.
    • library — Consumer app; reads from domain packages.
    • segment-evaluation — Internal tooling app; consumes video, editing.
    • video-processor — Deployable worker/service for heavy video jobs.
  • Packages (features)

    • video — Speech-to-text, transcript correction, capture/recording helpers, video domain types.
    • editing — Timeline/state, transforms, export/render orchestration (framework-agnostic).
    • auth — Shared auth surface (wrappers, guards, user/org access helpers).
    • branded-flow — Flow schemas, renderer primitives, SDK used by apps.
    • branded-flow-editor — Reusable editor engine (panels, commands, state).
    • design-system — UI primitives and patterns (shadcn/Tailwind-based).
    • core — Cross-cutting utilities (logging, telemetry, env access) with clear server/web surfaces.
    • storage — Unified storage abstractions (low-level + high-level) consumed by features.

Notes

  • The editor is a package; the app stays a thin host.
  • The worker remains an app; a tiny typed client can live in video if needed.

Entrypoints by Intent

Each feature exposes three stable entrypoints to make runtime boundaries explicit (a fourth, client, is optional when a typed browser/server-agnostic client is needed):

  • @repo/<feature>/server — Server-only APIs, adapters, and integrations (e.g., vendor SDK usage, storage drivers). Safe for RSC, server actions, services.
  • @repo/<feature>/web — Browser-safe APIs/components/hooks. No Node-only dependencies. Client components import from here.
  • @repo/<feature>/types — Feature-owned types and data contracts. Importable from anywhere. @repo/<feature>/client — Optional typed client for cross-runtime usage (e.g., SDK facade). Must remain environment-agnostic.

Benefits:

  • Apps import by intent, not by file path.
  • Tree-shaking-friendly, avoids accidental bundling of server-only code in the browser.
  • Aligns with the Shared/Web/Cloud split discussed in the restructuring PRD while keeping one-package-per-feature.

Import Patterns

Server code:

import { correctTranscript } from "@repo/video/server";
import { getSignedBlobUrl } from "@repo/storage/server";

Client component:

"use client";
import { useTimeline } from "@repo/editing/web";
import { Button } from "@repo/design-system/web";

Shared contracts:

import type { TranscriptionSegment } from "@repo/video/types";

Guardrails Checklist

  • Imports by intent:

    • Server code imports the server surface of a feature.
    • Client components import only the web surface and design-system primitives.
    • No central types: All types are feature-owned and imported from that feature’s types surface.
  • Apps stay thin: No heavy domain logic in route files; prefer calling feature surfaces and server actions.

  • Secrets and env:

    • Access only within invoked functions (never at module top-level).

    • Apps don’t reach vendor SDKs directly—features do (via server entrypoint).

    • Follow the monorepo environment getter pattern; never access process.env directly. Example:

      import { getEnv } from "@repo/core/server";
      export async function upload(...) {
        const key = getEnv("STORAGE_KEY");
        ...
      }
      
  • Dependency direction:

    • Apps → features; features avoid cyclic deps.
    • If a feature needs another, depend on its types or a narrow surface, not internal files.
  • Separation of deployables: Anything with its own runtime (e.g., video-processor) is an app; the rest is packaged.

  • Naming clarity: Package names reflect business capability (@repo/video, @repo/branded-flow), not technology buckets.

  • Split discipline: Create a new package only when ownership, dependencies, or deployment lifecycle differs materially.

  • Testing posture: Feature packages test themselves (unit/integration). Apps perform smoke/flow tests only.

  • Docs & ADRs: Each package keeps a brief README (scope, surfaces, examples) and records decisions as lightweight ADRs.

Sub-Path Exports (Implementation Note)

To support import-by-intent while keeping a single package per feature, use sub-path exports. This keeps implementation details internal and exposes stable surfaces. Example shape:

{
  "name": "@repo/video",
  "exports": {
    ".": "./dist/index.js",
    "./server": {
      "types": "./dist/server/index.d.ts",
      "default": "./dist/server/index.js"
    },
    "./web": {
      "types": "./dist/web/index.d.ts",
      "default": "./dist/web/index.js"
    },
    "./types": {
      "types": "./dist/types/index.d.ts",
      "default": "./dist/types/index.js"
    }
    },
  "types": "./dist/index.d.ts",
  "typesVersions": {
    "*": {
      "server": ["dist/server/index.d.ts"],
      "web": ["dist/web/index.d.ts"],
      "types": ["dist/types/index.d.ts"]
    }
  },
  "sideEffects": false
}

This aligns with the restructuring PRD’s emphasis on sub-path exports and avoids micro-packages.

Tooling Alignment

  • Turborepo: Keep turbo.json centralized; standardize build outputs to dist/**. Feature packages should participate in build, typecheck, and lint pipelines.
  • Workspaces: Root package.json workspaces must include both apps and packages; subfolders are allowed but avoid deep nesting that encourages micro-splits.
  • ESLint: Enforce boundaries per package:
    • Restrict importing server entrypoints from client modules.
    • Disallow use client in shared server surfaces.
    • Prevent cross-feature internal imports (only via exported surfaces).

Dev Tooling & Shared Config (ESLint)

  • Location: @repo/dev-tools (shared developer tooling, not runtime). Expose subpath exports for configs:

    {
      "name": "@repo/dev-tools",
      "exports": {
        "./eslint": "./eslint/index.cjs"
      }
    }
    
  • Usage (apps and packages):

    {
      "root": true,
      "extends": ["@repo/dev-tools/eslint"]
    }
    
  • Root ESLint config: keep a minimal .eslintrc.json that only extends @repo/dev-tools/eslint so editor tooling works at repo root. Do not add rules at the root; app/package configs may add local overrides when strictly needed.

  • Rationale: Centralizes import-boundary rules and intent-based surfaces while keeping feature packages focused on runtime code. Keeps with the feature-oriented model by placing dev-time concerns in a single tooling package rather than mixing into core (runtime utilities).

Example .eslintrc fragment:

{
  "rules": {
    "no-restricted-imports": [
      "error",
      {
        "patterns": [
          {"group": ["@repo/*/server"], "message": "Server surface cannot be imported from client modules", "contextRegex": ".*\\.(client|web)\\.(t|j)sx?$"},
          {"group": ["@repo/*/*/internal/**"], "message": "Import via exported surfaces only"}
        ]
      }
    ]
  }
}

Migration Guidance (when applicable)

  • Migrate features into a single package each; fold micro-packages into sub-areas (server, web, types).
  • Consolidate storage into a unified @repo/storage with sub-exports (low-level vs high-level) while preserving browser/server separation.
  • Move types into their owning feature; delete unused central types.
  • Provide transitional re-exports and codemods to update import paths.

Package README Template (per feature)

Each feature should include a compact README:

# @repo/<feature>

## Surfaces
- server: server-only APIs (adapters, SDK usage)
- web: browser-safe APIs/components
- types: shared contracts

## Examples
// brief import & usage examples

## Decisions
- ADR-YYYYMMDD-<slug>.md (1–2 paragraphs per decision)

Why This Works Here

  • Makes server/web boundaries explicit without proliferating packages.
  • Encourages domain ownership and clarifies where code belongs.
  • Reduces cognitive load compared to tech-based buckets; imports remain ergonomic and consistent.
  • Aligns with existing initiatives to standardize sub-path exports and avoid central types, as discussed in the restructuring PRD.

Incremental Next Step (2025-09-04)

  • Apply a narrow @repo/types → feature-owned types rewrite only within packages/video and packages/storage.
  • Generate and commit the unmatched report (scripts/codemods/types-to-feature.unmatched.txt) for deliberate follow-up.
  • Do not change any apps/* in this step; keep changes localized to feature packages.

Command snippet:

pnpm tsx scripts/codemods/types-to-feature.ts --apply --dir packages/video
pnpm tsx scripts/codemods/types-to-feature.ts --apply --dir packages/storage

Acceptance checks:

  • pnpm -w typecheck passes without new errors.
  • No diffs in apps/*.
  • Unmatched list exists and is referenced in the PR description for review.