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.yamlWhat is an App?
An app is a deployable entry point - something that runs as a process or gets served to users.
Characteristics:
- Has a
mainentry 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
Dockerfileor deployment configuration
Examples:
apps/api- Hono HTTP server, deployed to Railwayapps/admin- React SPA, deployed to Vercel/Cloudflareapps/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
mainthat 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:
- Apps can import packages -
apps/apican import@toast/db - Packages can import packages -
@toast/authcan import@toast/db - Apps cannot import apps -
apps/admincannot import fromapps/api - Packages cannot import apps -
@toast/dbcannot import fromapps/api - 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.jsonApps 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:
- Admin app needs DB too - Would create circular dependency or duplication
- Migrations belong to schema - They should live with the schema they manage
- Type sharing - Both apps need the same inferred types from Drizzle
- 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/admindoesn't redeployapps/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/dbvs../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
| Package | Purpose |
|---|---|
@toast/db | Drizzle schema, migrations, seed data, database client |
@toast/ui | Shared React components (Base UI-based, shadcn-generated) |
@toast/config | Environment validation schemas, shared Vitest configs |
| App | Purpose |
|---|---|
apps/api | Hono API server with OpenAPI documentation |
apps/admin | React admin panel (Vite + TanStack Router) |
References
- Turborepo Handbook: Package Boundaries
- Monorepo.tools
- Pattern Guides - Practical guides for building features