ADR-004: Monorepo with Bun Workspaces

Why Owlat uses a monorepo with Bun workspaces and Turborepo for fast installs, atomic cross-package changes, and cached CI.

  • Status: Accepted
  • Date: 2024-06-15

Context

Owlat consists of multiple interconnected pieces: a Nuxt frontend, a Convex backend, a documentation site, an email builder component, an email renderer library, shared types, and publishable SDKs. These packages share types and utilities extensively — for example, both the email builder and the email renderer depend on @owlat/shared for block type definitions.

The main options:

  1. Separate repositories — each package in its own repo with published npm packages for shared code. Clean boundaries but slow iteration: changing a shared type requires publishing, bumping versions, and updating consumers.
  2. Monorepo with npm/pnpm workspaces — colocated code with symlinked dependencies. Well-established but slower installs and more complex tooling.
  3. Monorepo with Bun workspaces — same workspace model but with Bun's fast install, native TypeScript execution, and Turbo for task orchestration.

Decision

Use a monorepo with Bun workspaces and Turborepo for task orchestration. All apps and packages live in a single repository under apps/ and packages/.

Structure:

apps/web        — @owlat/web (Nuxt frontend)
apps/api        — @owlat/api (Convex backend)
apps/docs       — @owlat/docs (Nuxt + @nuxt/content)
packages/shared — @owlat/shared (types, utilities)
packages/email-builder   — @owlat/email-builder
packages/email-renderer  — @owlat/email-renderer
packages/sdk-js          — @owlat/sdk-js
packages/ui              — @owlat/ui

This is a representative subset; the repo also contains additional apps (apps/mta, apps/marketing, apps/imap, apps/updater, apps/setup-cli, and more) and packages. Note that packages/sdk-java is a Java SDK and is explicitly excluded from the Bun workspaces (!packages/sdk-java in the root package.json workspaces.packages).

Consequences

Enables:

  • Atomic cross-package changes — a type change in shared and its consumers ship in one PR
  • Fast installs — Bun's linker is significantly faster than npm/pnpm for workspace resolution
  • Turbo caching — build outputs are cached (the build task in turbo.json declares outputs), so bun run build skips unchanged packages. Tests run uncached: the test task sets "cache": false.
  • Single CI pipeline — .github/workflows/test.yml (workflow name "CI") runs all workspace tests, via a per-workspace matrix that calls bun run --cwd <workspace> test:coverage for each workspace independently
  • Shared tooling — one oxlintrc.json, one oxfmtrc.json, one Turbo config
  • Changesets for SDK publishing — @changesets/cli manages versioning for publishable packages

Trade-offs:

  • All contributors need Bun installed (not just Node)
  • Large bun.lock file that can cause merge conflicts on dependency changes
  • Turbo adds a build orchestration layer that new contributors need to understand
  • CI runs all workspace checks even for single-package changes. Build caching helps, but tests are uncached; the test job mitigates cost with a per-workspace matrix in .github/workflows/test.yml so workspaces run in parallel rather than sequentially