Acme Platform is a Turborepo monorepo with pnpm workspaces. Two apps (web, api) consume shared packages. Apps are deployed independently; packages are never published to npm individually — they are workspace-internal only.
Responsibilities:
- All user-facing pages (public + protected)
- Better Auth route handler mounted at
/api/auth/* - Same-origin invitation bridge at
POST /api/invitations(proxied to the API) - Middleware (
proxy.ts) for fast cookie-based redirects before SSR - TanStack Query hooks for all server state
Auth lives here because Better Auth requires the auth route handler to be on the same origin as the frontend for cookie-based sessions.
Responsibilities:
- All application API routes under
/api/v1 - Session resolution on every protected request via
@acme/auth - Business logic delegation to service functions
- Prometheus metrics at
/metrics - BullMQ worker entrypoint (
src/worker.ts) — runs as a separate process in production
The worker shares all package dependencies with the HTTP server but runs independently so queue consumers don't block request handling.
Owns:
- Better Auth server configuration (plugins, adapters, callbacks)
- Session resolution helper used by both web and API
- RBAC permission helpers (
checkPermission, role constants) - Auth mailer — picks Resend → SMTP → in-memory capture based on available credentials
Nothing in this package runs a server. It exports pure configuration and helpers.
Owns:
- Drizzle ORM client factory
- All Drizzle schema definitions (auth tables generated by Better Auth, application tables)
- Migration runner (
src/migrate.ts) - Repository functions — typed read/write operations for each entity
Apps and packages import repository functions directly. There is no ORM model layer or active-record pattern — repositories are plain functions.
Owns:
- Zod schemas for every env var in the platform
- Two validated env loaders:
apiEnvandwebEnv - Runtime parsing fails fast with a clear message if required vars are missing
Owns:
- BullMQ queue and worker factory wrappers
- Typed job payload schemas
- Domain event fan-out helpers (
domainEvents.ts) — fire-and-forget signals that trigger downstream side effects (e.g. audit log → webhook delivery)
The jobs package is usable without Redis. Feature flags (asyncInviteEmail, outgoingWebhooks) auto-disable at runtime when REDIS_URL is absent, so the rest of the app continues to function.
Owns:
- Pino logger factory with request-context enrichment
- Loki transport (opt-in via
API_LOG_TO_LOKI=true) - Development pretty-printer
Owns:
- OpenTelemetry SDK bootstrap (
initOtel) - Span helper utilities
- Becomes a no-op when
OTEL_EXPORTER_OTLP_ENDPOINTis unset
Owns:
- Transport-neutral Zod schemas and TypeScript types for API contracts
- Response envelope helpers (
ok,err) - Shared constants
Used by both apps/web (typed API client) and apps/api (route validation).
Owns:
- shadcn-based component primitives
- Tailwind CSS globals
- All React component dependencies are peer dependencies of this package
Owns:
- TypeScript source for the
create-acme-platformnpm CLI - Built by tsup into
dist/index.mjsas part of the release pipeline - Not consumed by any other workspace package — exists purely for the CLI distribution
apps/* → can import any packages/*
packages/* → can import other packages/* (no circular deps)
packages/db → owns all persistence; apps never call Drizzle directly
packages/auth → owns all session logic; apps never call Better Auth directly
apps/* → never import from each other
Browser POST /api/auth/sign-in
→ apps/web Better Auth route handler
→ @acme/auth server config → PostgreSQL (session + user table write)
→ Set-Cookie: better_auth_session
Browser GET /users (protected page)
→ apps/web middleware (reads cookie, redirects if missing)
→ Next.js server component
→ @acme/auth session resolution → PostgreSQL (session lookup)
→ renders with session data
Browser GET /api/v1/users
→ apps/api Hono route
→ session middleware → @acme/auth resolveSession → PostgreSQL
→ RBAC check (@acme/auth checkPermission)
→ service function
→ @acme/db repository → PostgreSQL
→ @acme/shared response envelope
→ Pino request log → Loki (if enabled)
→ OTel span → Collector → Tempo
POST /api/v1/invitations
→ creates invitation in Better Auth
→ if asyncInviteEmail flag ON:
→ @acme/jobs enqueue InviteEmailJob → Redis
→ 202 response returned immediately
→ BullMQ Worker picks up job
→ @acme/auth mailer → Resend or SMTP
→ if asyncInviteEmail flag OFF:
→ @acme/auth mailer sends inline
→ 201 response
POST /api/v1/invitations/:id/accept
→ @acme/db write (org member create)
→ @acme/db write (audit log)
→ @acme/jobs domainEvent("org.member.added")
→ if outgoingWebhooks flag ON:
→ load registered webhook endpoints from DB
→ enqueue signed delivery jobs for each endpoint
→ Worker delivers with retry
Why Hono instead of Express? Hono is standards-based (Request/Response), has first-class TypeScript, and has near-zero overhead. It also works on edge runtimes if deployment needs change.
Why Better Auth instead of NextAuth? Better Auth provides a database-backed session model, a built-in organization plugin, and a clean separation between the auth server config and the session resolution helper. It doesn't require JWTs or stateless tokens.
Why Drizzle instead of Prisma? Drizzle is SQL-first with zero runtime overhead. Schema is TypeScript, migrations are plain SQL, and there's no query engine process to manage. Repositories stay as plain typed functions.
Why BullMQ instead of a managed queue? Redis is already a dependency for session work. BullMQ gives persistent, retriable, typed queues without adding another infrastructure service. The feature-flag guard means the platform degrades gracefully when Redis is unavailable.
Why pnpm + Turborepo? pnpm's strict dependency isolation prevents phantom dependencies. Turborepo's task graph and remote caching make CI fast — only packages affected by a change are rebuilt.