Skip to content

Latest commit

 

History

History
201 lines (140 loc) · 6.63 KB

File metadata and controls

201 lines (140 loc) · 6.63 KB

Architecture

Overview

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.

Application Layer

apps/web — Next.js Frontend

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.

apps/api — Hono API

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.

Package Layer

@acme/auth

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.

@acme/db

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.

@acme/config

Owns:

  • Zod schemas for every env var in the platform
  • Two validated env loaders: apiEnv and webEnv
  • Runtime parsing fails fast with a clear message if required vars are missing

@acme/jobs

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.

@acme/logger

Owns:

  • Pino logger factory with request-context enrichment
  • Loki transport (opt-in via API_LOG_TO_LOKI=true)
  • Development pretty-printer

@acme/observability

Owns:

  • OpenTelemetry SDK bootstrap (initOtel)
  • Span helper utilities
  • Becomes a no-op when OTEL_EXPORTER_OTLP_ENDPOINT is unset

@acme/shared

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).

@acme/ui

Owns:

  • shadcn-based component primitives
  • Tailwind CSS globals
  • All React component dependencies are peer dependencies of this package

@acme/cli

Owns:

  • TypeScript source for the create-acme-platform npm CLI
  • Built by tsup into dist/index.mjs as part of the release pipeline
  • Not consumed by any other workspace package — exists purely for the CLI distribution

Layer Rules

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

Data Flow

Authentication

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

API Request

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

Async Job

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

Domain Events

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

Design Decisions

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.