SaaS Starter

Bounded contexts

Os sete módulos entregues em apps/server/src/modules/, o que cada um possui, e o que cruza a fronteira.

Um bounded context = uma pasta sob apps/server/src/modules/ com seu próprio domain/, application/, infrastructure/, interfaces/http/. Módulos não importam de domain/ ou application/ uns dos outros. Eles se comunicam via:

  1. Eventos de domínio no event bus em memória (preferido para fan-out).
  2. HTTP quando se quer um contract de API estável.
  3. Shared kernel em packages/shared (apenas para primitives genuínos — IDs, erros, Result).

iam — Identity & access

apps/server/src/modules/iam/

Possui User, verificação de email, sessions (delegadas ao 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 Prisma, glue do 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 permissions users:*.

Flows de auth baseados em cookies funcionam porque apiClient.withCredentials = true no client. Não reintroduza bearer tokens.

tenancy — Organizações & memberships

apps/server/src/modules/tenancy/

Possui Organization, Membership, linhas dinâmicas de Role, e o middleware organizationContext que hidrata req.organization (org ativa) e suas permissions computadas para o usuário atual.

Um usuário pode pertencer a várias organizações; cada membership tem seu próprio role e, portanto, seu próprio grant de permissions. Trocar de org = atualizar o cookie active-org; a próxima request vê um req.grants diferente.

billing — Subscriptions, plans, providers

apps/server/src/modules/billing/

Possui Subscription, Plan, ingest de webhooks. Provider-agnostic: interface PaymentProvider em domain/, três implementações concretas em infrastructure/ (Stripe, Mercado Pago, Polar). O env PAYMENT_PROVIDER decide qual é vinculada.

Webhooks pousam em /webhooks/<provider>, são verificados, se transformam em eventos de domínio (subscription.activated, subscription.canceled, …), e atualizam a linha local de Subscription. O frontend lê Subscription.status — nunca a API do provider.

O middleware requireActiveSubscription (infrastructure/http/require-subscription.ts) gateia endpoints premium.

usage — Metering de free-tier e enforcement de cotas

apps/server/src/modules/usage/

Possui UsageMeterDefinition, UsageCounter, UsageEvent. Produtos downstream registram meters no boot (ex.: tickets_created, jobs_created), fornecem um mapa plano → cap, e chamam QuotaEnforcer.checkAndIncrement a partir dos use cases que produzem eventos faturáveis. Lê a subscription ativa de billing via o port cross-module IGetActiveSubscriptionPort — sem tocar as tabelas de billing.

Três cadências de reset (lifetime / monthly / yearly); as janelas periódicas rolam via o job BullMQ usage-period-rollover. Desabilitado por default (USAGE_METERING_ENABLED=false); ver Usage metering para a referência completa.

notifications — Email & in-app

apps/server/src/modules/notifications/

application/ declara o port (EmailSender); infrastructure/ provê Resend, SES, e um adapter no-op. Listeners (ex. user.created → welcome email) vivem no bootstrap e enfileiram um job BullMQ emails; o worker pega e chama o sender.

Por isso este módulo não tem pasta domain/ — não há aggregate, apenas um side effect.

storage — File uploads

apps/server/src/modules/storage/

Possui uploads de avatar (e qualquer outro binário que a app precise). Interface StorageProvider com implementações null / local / s3 / uploadthing. STORAGE_PROVIDER=null retorna 503 dos endpoints de upload — sem falha silenciosa.

As keys são content-addressed: avatars/{userId}-{sha256:16}.{ext}. O hash torna seguro o header de cache imutável (Cache-Control: public, max-age=31536000, immutable) entre re-uploads — um novo upload escreve uma nova key, então um CDN nunca serve bytes obsoletos.

URLs do provider local são construídas a partir de BETTER_AUTH_URL (a URL do server) porque o static middleware que serve uploads roda no server, não no Next.js.

audit — Audit log

apps/server/src/modules/audit/

Registro append-only de ações sensíveis (mudanças de role, eventos de billing, mutações admin de users). Listeners no event bus escrevem as linhas; não há API pública de mutação.

feature-flags — Gates de runtime

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

Flags por org, por usuário ou globais. Leituras são cacheadas in-process; escritas invalidam. O middleware req.featureFlags expõe um accessor tipado para que os handlers não espalhem string keys.

O que cruza uma fronteira

ConcernMecanismo
Usuário se cadastra → enviar welcome emailiam publica user.created → listener de notifications enfileira job
Webhook do Stripe ativa subscriptionbilling publica subscription.activated → listener de audit escreve log
Endpoint precisa saber se a subscription está ativaMiddleware lê tenancybilling (chamada cross-module estilo HTTP dentro do processo)
Branded ID, Result, DomainError@app/shared
Forma de body / response HTTP@app/contracts

Se você se pegar alcançando do domain/ de um módulo para o de outro, pare — isso é um evento ou um port, não um import.

apps/server/src/modules/links/

Vertical link-in-bio para coaches e consultores — o produto mibio. Page é o perfil público (slug, theme, estado de publicação). Block é a entrada do smart link com type + config: jsonb, validada por schemas Zod por BlockType (LINK, HEADER, PAYMENT_LINK, BOOKING_LINK, WHATSAPP, LEAD_CAPTURE, IMAGE, SOCIAL_ROW; EMBED e TESTIMONIAL reservados). A ordem dos blocks usa inteiros position esparsos (10, 20, 30…) para que um reorder não precise renumerar todas as linhas.

Lead registra capturas dos blocks LEAD_CAPTURE. @@unique([pageId, email]) previne cadastros duplicados por página.

Os repositórios seguem o layout DDD padrão. A superfície HTTP é montada em /api/v1/links/* com permissões links:read / links:write; dados PII de leads são gateados separadamente por links:leads:read / links:leads:export.

Superfície pública. Dois endpoints sem auth alimentam a página ao vivo:

  • GET /api/v1/public/pages/by-slug/:slug — devolve a página publicada + blocks habilitados, servido por um cache LRU in-process (60s TTL, 1000 entradas) que envolve os repos Prisma. A invalidação vive em infrastructure/cache/caching-repositories.ts: cada save / delete na página ou em um block remove as chaves slug + page-id correspondentes, então as escritas do admin aparecem na próxima leitura pública. A response seta X-Cache: HIT|MISS para debug + Cache-Control: public, max-age=60, stale-while-revalidate=300 para qualquer CDN à frente cachear de forma coerente.
  • POST /api/v1/public/leads — captura { blockId, email, name? }, deriva pageId do block (a superfície pública não pode spoofar outra página), upsert em (pageId, email) para deduplicar, rate-limited por IP via o tier authIpLimiter.

Templates. Quatro arquétipos de coach hardcoded em modules/links/infrastructure/templates/registry.ts (Coach Ejecutivo, Wellness, Terapeuta, Consultor B2B). NÃO são um modelo Prisma — na criação da página, o use case applyTemplate traduz um BlockSeed[] em linhas reais de Block nas posições 10, 20, 30, … e copia o theme, title e bio do template para a página. GET /api/v1/links/templates lista (name / description / thumbnail).

Render público. A rota catch-all do Next App Router app/[slug]/page.tsx faz SSR fetch a /api/v1/public/pages/by-slug/:slug e renderiza a página com oito componentes de block sob apps/client/components/public/blocks/ (LeadCapture é o único client component — gerencia o estado do form). Slugs reservados são filtrados server-side via apps/client/lib/public/reserved-slugs.ts (mirror do SLUG_RESERVED do contrato + rotas de marketing) então /admin, /login, etc. não colidem com rotas reais. Uma imagem OG dinâmica em app/[slug]/opengraph-image.tsx (next/og) renderiza um PNG 1200×630 do perfil para os links compartilhados.

Editor é plano 4.

analytics (mibio)

apps/server/src/modules/analytics/

Event store append-only para tráfego das páginas mibio. PageView e BlockClick são linhas imutáveis; DailyRollup é o agregado materializado ((pageId, date) único) que alimenta o tab de analytics do editor sem re-escanear eventos brutos.

Caminho de ingest. Dois endpoints POST públicos sem auth em /api/v1/public/analytics/{view,click} validam um payload Zod pequeno e retornam 204 antes de fazer o trabalho. O handler resolve a página (por slug para views, por blockId para clicks) e insere uma linha; payloads inválidos / slugs desconhecidos são logados em warn e nunca chegam ao caller. Isto é fire-and-forget por design — a página pública SSR emite esses beacons via navigator.sendBeacon (clicks) e fetch({ keepalive: true }) (view inicial), então não bloqueiam o render nem são cancelados ao navegar para uma URL externa.

Caminho de leitura. GET /api/v1/links/pages/:id/analytics?range=7d|30d|90d retorna totais + clicks por block + série densa por dia. Gateado por links:read e scoped à organização dona.

Jobs em background. Dois schedules BullMQ:

  • analytics-rollup — diário às 03:15 UTC. Para cada página com atividade nas últimas 36h, materializa os totais de ontem em DailyRollup. Idempotente via unique key em (pageId, date).
  • analytics-prune — semanal aos domingos 04:00 UTC. Apaga linhas raw de PageView + BlockClick com mais de 90 dias. DailyRollup é conservado para sempre (barato, uma linha por página por dia).

Ambos os processors vivem em analytics/infrastructure/jobs/ e se registram em bootstrap/worker.ts; o installer do schedule (scheduleAnalyticsJobs) roda em cada cold start a partir de index.ts e é no-op quando Redis não está configurado.

O render público já está vivo (ver links acima). A UI de analytics do editor chega no plano 4.

Nesta página