Toast
ContributorDecisions

ADR 002: Monorepo Structure - Apps vs Packages

ADR 002: Monorepo Structure - Apps vs Packages

Status

Accepted

Context

We're building a modern TypeScript monorepo and need clear conventions for organizing code. Key requirements:

  • Multiple deployable applications (API, admin UI, public web)
  • Shared code between applications (database, auth, UI components)
  • Clear dependency boundaries to prevent circular imports
  • Independent versioning and deployment of applications
  • Fast builds via Turborepo's caching

Decision

Adopt a two-tier structure separating apps (deployable) from packages (shared libraries).

project/
├── apps/           # Deployable applications
│   ├── api/        # Hono API server
│   ├── admin/      # React admin client
│   └── web/        # Public-facing renderer
├── packages/       # Shared libraries
│   ├── @toast/db/  # Database schema, migrations, client
│   ├── @toast/auth/
│   ├── @toast/shared/
│   └── @toast/ui/
├── turbo.json
└── pnpm-workspace.yaml

What is an App?

An app is a deployable entry point - something that runs as a process or gets served to users.

Characteristics:

  • Has a main entry point (starts a server, renders HTML, etc.)
  • Gets deployed independently (has its own Railway service, Vercel project, etc.)
  • Consumes packages but is never imported by other code
  • Contains application-specific configuration (environment handling, middleware composition)
  • Has its own Dockerfile or deployment configuration

Examples:

  • apps/api - Hono HTTP server, deployed to Railway
  • apps/admin - React SPA, deployed to Vercel/Cloudflare
  • apps/web - Public site renderer, deployed separately

What is a Package?

A package is a shared library - reusable code imported by apps or other packages.

Characteristics:

  • Exports functions, types, components, or configuration
  • Never runs on its own (no main that starts a process)
  • Can be imported by multiple apps
  • Has a clear, focused responsibility
  • Versioned together with the monorepo (internal packages)

Examples:

  • @toast/db - Drizzle schema, migrations, database client
  • @toast/auth - Authentication configuration and utilities
  • @toast/shared - Zod schemas, utility functions, shared types
  • @toast/ui - React component library

Package Naming Convention

Internal packages use the @toast/ scope:

{
  "name": "@toast/db",
  "private": true
}

The private: true ensures they're never accidentally published to npm.

Dependency Rules

Enforce these boundaries via ESLint:

  1. Apps can import packages - apps/api can import @toast/db
  2. Packages can import packages - @toast/auth can import @toast/db
  3. Apps cannot import apps - apps/admin cannot import from apps/api
  4. Packages cannot import apps - @toast/db cannot import from apps/api
  5. No circular dependencies - If A imports B, B cannot import A
apps/api ──────► @toast/db ──────► @toast/shared
    │                                    ▲
    └──────────► @toast/auth ────────────┘

Database Package Structure

The @toast/db package contains everything database-related:

packages/db/
├── src/
│   ├── index.ts      # Exports client, schema, types
│   ├── client.ts     # Database connection (getDb, checkConnection)
│   ├── schema.ts     # All table definitions
│   ├── migrate.ts    # Migration runner
│   └── seed.ts       # Seed script
├── fixtures/
│   └── content.json  # Sample data for seeding
├── migrations/       # Generated migrations (do not edit)
│   ├── 0000_*.sql
│   └── meta/
├── drizzle.config.ts
└── package.json

Apps import from it via repositories (not directly):

// apps/api/src/repositories/content.repository.ts
import { content, getDb } from '@toast/db';

export async function findAll() {
  const db = getDb();
  return db.select().from(content);
}

See ADR-004 for the full layered architecture.

Why Not Keep DB in apps/api?

Putting database code in apps/api creates problems:

  1. Admin app needs DB too - Would create circular dependency or duplication
  2. Migrations belong to schema - They should live with the schema they manage
  3. Type sharing - Both apps need the same inferred types from Drizzle
  4. Testing - Packages can be tested in isolation

Consequences

Positive

  • Clear mental model - "Where does this go?" has an obvious answer
  • Enforced boundaries - ESLint catches violations at lint time
  • Parallel builds - Turborepo builds independent packages concurrently
  • Selective deployment - Change to apps/admin doesn't redeploy apps/api
  • Shared code is explicit - No accidental tight coupling between apps

Negative

  • More packages to manage - Each package needs its own package.json, tsconfig.json
  • Import paths are longer - @toast/db vs ../db
  • Initial setup cost - More boilerplate than a single-app repo

Risks

  • Over-extraction - Creating packages for code only used in one place
  • Package explosion - Too many tiny packages increase cognitive overhead

Mitigation: Start with fewer, larger packages. Extract when there's a clear need (multiple consumers or distinct domain).

Current Package Summary

PackagePurpose
@toast/dbDrizzle schema, migrations, seed data, database client
@toast/uiShared React components (Base UI-based, shadcn-generated)
@toast/configEnvironment validation schemas, shared Vitest configs
AppPurpose
apps/apiHono API server with OpenAPI documentation
apps/adminReact admin panel (Vite + TanStack Router)

References

On this page