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 inpackages/*. - 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
videoif 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
typessurface.
-
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
serverentrypoint). -
Follow the monorepo environment getter pattern; never access
process.envdirectly. 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
typesor 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.jsoncentralized; standardize build outputs todist/**. Feature packages should participate inbuild,typecheck, andlintpipelines. - Workspaces: Root
package.jsonworkspaces must include both apps and packages; subfolders are allowed but avoid deep nesting that encourages micro-splits. - ESLint: Enforce boundaries per package:
- Restrict importing
serverentrypoints from client modules. - Disallow
use clientin shared server surfaces. - Prevent cross-feature internal imports (only via exported surfaces).
- Restrict importing
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.jsonthat only extends@repo/dev-tools/eslintso 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/storagewith 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 withinpackages/videoandpackages/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 typecheckpasses without new errors.- No diffs in
apps/*. - Unmatched list exists and is referenced in the PR description for review.