SaaS Starter

Bounded contexts

Los siete módulos incluidos en apps/server/src/modules/, qué posee cada uno, y qué cruza la frontera.

Un bounded context = una carpeta bajo apps/server/src/modules/ con su propio domain/, application/, infrastructure/, interfaces/http/. Los módulos no importan desde domain/ o application/ de otro. Se comunican mediante:

  1. Eventos de dominio sobre el event bus en memoria (preferido para fan-out).
  2. HTTP cuando se desea un contrato de API estable.
  3. Shared kernel en packages/shared (sólo para primitives genuinos — IDs, errores, Result).

iam — Identidad y acceso

apps/server/src/modules/iam/

Posee User, verificación de email, sessions (delegadas a BetterAuth). Repo:

domain/
  user.ts                  Aggregate. Username, locale, picture, active flag, lastLogin.
  email.ts                 Value object.
  user-repository.ts       Interface.
application/
  ports/
  use-cases/               Register, login, change-password, update-profile, list-users…
infrastructure/
  Adapter de Prisma, glue de BetterAuth.
interfaces/http/
  auth.controller.ts       /api/auth/*
  auth.middleware.ts       authMiddleware (401), sessionHydration (anon-friendly).
  users.routes.ts          /api/users — admin CRUD gateado por permisos users:*.

Los flows de auth basados en cookies funcionan porque apiClient.withCredentials = true en el client. No reintroduzcas bearer tokens.

tenancy — Organizaciones y memberships

apps/server/src/modules/tenancy/

Posee Organization, Membership, filas dinámicas de Role, y el middleware organizationContext que hidrata req.organization (org activa) y sus permisos computados para el user actual.

Un user puede pertenecer a muchas organizaciones; cada membership tiene su propio rol y, por tanto, su propio grant de permisos. Cambiar de org = actualizar la cookie de active-org; la próxima request ve un req.grants distinto.

billing — Subscriptions, plans, providers

apps/server/src/modules/billing/

Posee Subscription, Plan, ingest de webhooks. Provider-agnostic: interface PaymentProvider en domain/, tres implementaciones concretas en infrastructure/ (Stripe, Mercado Pago, Polar). El env PAYMENT_PROVIDER decide cuál se enlaza.

Los webhooks aterrizan en /webhooks/<provider>, son verificados, se convierten en eventos de dominio (subscription.activated, subscription.canceled, …), y actualizan la fila local de Subscription. El frontend lee Subscription.status — nunca la API del provider.

El middleware requireActiveSubscription (infrastructure/http/require-subscription.ts) gatea endpoints premium.

usage — Metering de free-tier y enforcement de cuotas

apps/server/src/modules/usage/

Posee UsageMeterDefinition, UsageCounter, UsageEvent. Los productos downstream registran meters al boot (p.ej. tickets_created, jobs_created), proveen un map plan → cap, y llaman a QuotaEnforcer.checkAndIncrement desde los use cases que producen eventos facturables. Lee la subscription activa de billing vía el port cross-module IGetActiveSubscriptionPort — sin tocar las tablas de billing.

Tres cadencias de reset (lifetime / monthly / yearly); las ventanas periódicas rolan vía el job BullMQ usage-period-rollover. Deshabilitado por default (USAGE_METERING_ENABLED=false); ver Usage metering para la referencia completa.

notifications — Email & in-app

apps/server/src/modules/notifications/

application/ declara el port (EmailSender); infrastructure/ provee Resend, SES, y un adapter no-op. Los listeners (ej. user.created → welcome email) viven en bootstrap y encolan un job BullMQ emails; el worker lo levanta y llama al sender.

Por eso este módulo no tiene carpeta domain/ — no hay aggregate, sólo un side effect.

storage — File uploads

apps/server/src/modules/storage/

Posee los uploads de avatares (y cualquier otro binario que la app necesite). Interface StorageProvider con implementaciones null / local / s3 / uploadthing. STORAGE_PROVIDER=null devuelve 503 desde los endpoints de upload — sin falla silenciosa.

Las keys son content-addressed: avatars/{userId}-{sha256:16}.{ext}. El hash hace seguro al header de cache inmutable (Cache-Control: public, max-age=31536000, immutable) entre re-uploads — un nuevo upload escribe una key nueva, así que un CDN nunca sirve bytes obsoletos.

Las URLs del provider local se construyen desde BETTER_AUTH_URL (la URL del server) porque el static middleware que sirve los uploads corre en el server, no en Next.js.

audit — Audit log

apps/server/src/modules/audit/

Registro append-only de acciones sensibles (cambios de rol, eventos de billing, mutaciones admin de users). Los listeners en el event bus escriben las filas; no hay API pública de mutación.

feature-flags — Gates de runtime

apps/server/src/modules/feature-flags/

Flags por org, por user o globales. Las lecturas se cachean in-process; las escrituras invalidan. El middleware req.featureFlags expone un accessor tipado para que los handlers no salpiquen string keys.

Qué cruza una frontera

ConcernMecanismo
User se registra → enviar welcome emailiam publica user.created → listener de notifications encola un job
Webhook de Stripe activa subscriptionbilling publica subscription.activated → listener de audit escribe el log
Endpoint necesita saber si la subscription está activaMiddleware lee tenancybilling (llamada cross-module estilo HTTP dentro del proceso)
Branded ID, Result, DomainError@app/shared
Forma de body / response HTTP@app/contracts

Si te encontrás llegando desde domain/ de un módulo al de otro, frená — eso es un evento o un port, no un import.

apps/server/src/modules/links/

Vertical link-in-bio para coaches y consultores — el producto mibio. Page es el perfil público (slug, theme, estado de publicación). Block es la entrada de smart link con type + config: jsonb, validado por schemas Zod por BlockType (LINK, HEADER, PAYMENT_LINK, BOOKING_LINK, WHATSAPP, LEAD_CAPTURE, IMAGE, SOCIAL_ROW; EMBED y TESTIMONIAL reservados). El orden de blocks usa enteros position esparcidos (10, 20, 30…) para que un reorder no tenga que renumerar todas las filas.

Lead registra capturas de los blocks LEAD_CAPTURE. @@unique([pageId, email]) previene altas duplicadas por página.

Los repositorios siguen el layout DDD estándar. La superficie HTTP se monta en /api/v1/links/* con permisos links:read / links:write; los datos PII de leads se gatean por separado con links:leads:read / links:leads:export.

Superficie pública. Dos endpoints sin auth alimentan la página en vivo:

  • GET /api/v1/public/pages/by-slug/:slug — devuelve la página publicada + blocks habilitados, servido por un cache LRU in-process (60s TTL, 1000 entradas) que envuelve los repos Prisma. La invalidación vive en infrastructure/cache/caching-repositories.ts: cada save / delete sobre la página o un block desaloja las claves slug + page-id correspondientes, así las escrituras del admin se ven en el siguiente read público. La response setea X-Cache: HIT|MISS para debug + Cache-Control: public, max-age=60, stale-while-revalidate=300 para que cualquier CDN al frente cachee de forma coherente.
  • POST /api/v1/public/leads — captura { blockId, email, name? }, deriva pageId desde el block (así la superficie pública no puede spoofear otra página), upsert sobre (pageId, email) para deduplicar, rate-limited por IP vía el tier authIpLimiter.

Templates. Cuatro arquetipos de coach hardcodeados en modules/links/infrastructure/templates/registry.ts (Coach Ejecutivo, Wellness, Terapeuta, Consultor B2B). NO son un modelo de Prisma — al crear una página, el use case applyTemplate traduce un BlockSeed[] a filas reales de Block en posiciones 10, 20, 30, … y copia el theme, título y bio del template a la página. GET /api/v1/links/templates los lista (name / description / thumbnail).

Render público. La ruta catch-all del Next App Router app/[slug]/page.tsx hace SSR fetch a /api/v1/public/pages/by-slug/:slug y renderiza la página con ocho componentes de block bajo apps/client/components/public/blocks/ (LeadCapture es el único client component — administra el estado del form). Slugs reservados se filtran server-side vía apps/client/lib/public/reserved-slugs.ts (mirror del SLUG_RESERVED del contrato más rutas de marketing) así /admin, /login, etc. no chocan con rutas reales. Una imagen OG dinámica en app/[slug]/opengraph-image.tsx (next/og) renderiza un PNG 1200×630 del perfil para los links compartidos.

Editor es plan 4.

analytics (mibio)

apps/server/src/modules/analytics/

Event store append-only para tráfico de páginas mibio. PageView y BlockClick son filas inmutables; DailyRollup es el agregado materializado ((pageId, date) único) que alimenta el tab de analytics del editor sin re-escanear eventos crudos.

Ruta de ingesta. Dos endpoints POST públicos y sin auth bajo /api/v1/public/analytics/{view,click} validan un payload Zod chico y retornan 204 antes de hacer el trabajo. El handler resuelve la página (por slug para views, por blockId para clicks) e inserta una fila; payloads inválidos / slugs desconocidos se loguean en warn y nunca llegan al caller. Esto es fire-and-forget por diseño — la página pública SSR emite estos beacons vía navigator.sendBeacon (clicks) y fetch({ keepalive: true }) (view inicial), así no bloquean el render ni se cancelan al navegar a una URL externa.

Ruta de lectura. GET /api/v1/links/pages/:id/analytics?range=7d|30d|90d retorna totales + clicks por block + serie densa por día. Gateado por links:read y scoped a la organización dueña.

Jobs en background. Dos schedules BullMQ:

  • analytics-rollup — diario a las 03:15 UTC. Para cada página con actividad en las últimas 36h, materializa los totales de ayer en DailyRollup. Idempotente vía la unique key en (pageId, date).
  • analytics-prune — semanal domingos 04:00 UTC. Borra filas raw de PageView + BlockClick con más de 90 días. DailyRollup se conserva para siempre (barato, una fila por página por día).

Ambos processors viven en analytics/infrastructure/jobs/ y se registran en bootstrap/worker.ts; el installer del schedule (scheduleAnalyticsJobs) corre en cada cold start desde index.ts y es no-op si Redis no está configurado.

El render público ya está vivo (ver links arriba). La UI de analytics del editor llega en el plan 4.

En esta página