Bounded contexts
The seven modules shipped in apps/server/src/modules/, what each owns, and what crosses the boundary.
A bounded context = a folder under apps/server/src/modules/ with its own domain/, application/, infrastructure/, interfaces/http/. Modules don't import from each other's domain/ or application/. They communicate through:
- Domain events on the in-memory event bus (preferred for fan-out).
- HTTP when a stable API contract is wanted.
- Shared kernel in
packages/shared(only for true primitives — IDs, errors,Result).
iam — Identity & access
apps/server/src/modules/iam/
Owns User, email verification, sessions (delegated to 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/
Prisma adapter, BetterAuth glue.
interfaces/http/
auth.controller.ts /api/auth/*
auth.middleware.ts authMiddleware (401), sessionHydration (anon-friendly).
users.routes.ts /api/users — admin CRUD gated by users:* permissions.Cookie-based auth flows because apiClient.withCredentials = true on the client. Don't reintroduce bearer tokens.
tenancy — Organizations & memberships
apps/server/src/modules/tenancy/
Owns Organization, Membership, dynamic Role rows, and the organizationContext middleware that hydrates req.organization (active org) and its computed permissions for the current user.
A user can belong to many organizations; each membership has its own role and therefore its own permission grant. Switching org = updating the active-org cookie; the next request sees a different req.grants.
billing — Subscriptions, plans, providers
apps/server/src/modules/billing/
Owns Subscription, Plan, webhook ingest. Provider-agnostic: PaymentProvider interface in domain/, three concrete implementations in infrastructure/ (Stripe, Mercado Pago, Polar). PAYMENT_PROVIDER env decides which one binds.
Webhooks land at /webhooks/<provider>, get verified, are turned into domain events (subscription.activated, subscription.canceled, …), and update the local Subscription row. The frontend reads Subscription.status — never the provider's API.
requireActiveSubscription middleware (infrastructure/http/require-subscription.ts) gates premium endpoints.
usage — Free-tier metering & quota enforcement
apps/server/src/modules/usage/
Owns UsageMeterDefinition, UsageCounter, UsageEvent. Downstream products register meters at boot (e.g. tickets_created, jobs_created), provide a plan → cap map, and call QuotaEnforcer.checkAndIncrement from use cases that produce billable events. Reads the active subscription from billing via the IGetActiveSubscriptionPort cross-module port — no peeking into billing's tables.
Three reset cadences (lifetime / monthly / yearly); periodic windows roll over via the usage-period-rollover BullMQ job. Disabled by default (USAGE_METERING_ENABLED=false); see Usage metering for the full reference.
notifications — Email & in-app
apps/server/src/modules/notifications/
application/ declares the port (EmailSender); infrastructure/ provides Resend, SES, and a no-op adapter. Listeners (e.g. user.created → welcome email) live in bootstrap and enqueue a emails BullMQ job; the worker pulls it off and calls the sender.
This is why this module has no domain/ folder — there's no aggregate, only a side effect.
storage — File uploads
apps/server/src/modules/storage/
Owns avatar uploads (and any other binary the app needs). StorageProvider interface with null / local / s3 / uploadthing implementations. STORAGE_PROVIDER=null returns 503 from upload endpoints — no silent failure.
Keys are content-addressed: avatars/{userId}-{sha256:16}.{ext}. The hash makes the immutable cache header (Cache-Control: public, max-age=31536000, immutable) safe across re-uploads — a new upload writes a new key, so a CDN never serves stale bytes.
Local-provider URLs are built from BETTER_AUTH_URL (the server's URL) because the static middleware that serves uploads runs on the server, not on Next.js.
audit — Audit log
apps/server/src/modules/audit/
Append-only record of sensitive actions (role changes, billing events, admin user mutations). Listeners on the event bus write rows; there is no public mutation API.
feature-flags — Runtime gates
apps/server/src/modules/feature-flags/
Per-org, per-user, or global flags. Reads are cached in-process; writes invalidate. The req.featureFlags middleware exposes a typed accessor so handlers don't sprinkle string keys.
What crosses a boundary
| Concern | Mechanism |
|---|---|
| User signs up → send welcome email | iam publishes user.created → notifications listener enqueues job |
| Stripe webhook activates subscription | billing publishes subscription.activated → audit listener writes log |
| Endpoint needs to know if subscription is active | Middleware reads tenancy → billing (cross-module HTTP-style call inside the process) |
Branded ID, Result, DomainError | @app/shared |
| HTTP body / response shape | @app/contracts |
If you find yourself reaching from one module's domain/ into another's, stop — that's an event or a port, not an import.
Cross-cutting: platform admin
The platform admin console (/admin/* and /api/v1/platform/*) is deliberately hybrid. Cross-cutting infrastructure — first-run bootstrap, impersonation, the requirePlatformAdmin middleware, the platform overview reader — lives in modules/platform/ so the auth and gating story is centralized. Mutations live in the owning context: user lifecycle in iam/, org lifecycle in tenancy/, billing operations in billing/, audit reads in audit/, flag toggles in feature-flags/. Each module exposes an interfaces/http/admin.routes.ts mounted under /api/v1/platform. See Platform admin for the user-facing surface.
links (mibio)
apps/server/src/modules/links/
Vertical link-in-bio for coaches and consultants — the mibio product. Page
is the public profile (slug, theme, publish state). Block is the smart
link entry with type + config: jsonb, validated by Zod schemas per
BlockType (LINK, HEADER, PAYMENT_LINK, BOOKING_LINK, WHATSAPP,
LEAD_CAPTURE, IMAGE, SOCIAL_ROW; EMBED and TESTIMONIAL reserved).
Block ordering uses sparse position integers (10, 20, 30…) so reorders
don't have to renumber every row.
Lead records form captures from LEAD_CAPTURE blocks. @@unique([pageId, email])
prevents duplicate signups per page.
Repositories follow the standard DDD layout. HTTP surface mounted at
/api/v1/links/* with links:read / links:write permissions; lead PII
is gated separately by links:leads:read / links:leads:export.
Public surface. Two unauthenticated endpoints power the live page:
GET /api/v1/public/pages/by-slug/:slug— returns the published page + enabled blocks, served by an in-process LRU cache (60s TTL, 1000 entries) wrapped around the Prisma repos. Cache invalidation lives ininfrastructure/cache/caching-repositories.ts: everysave/deleteon the page or a block evicts the relevant slug + page-id keys, so admin writes are visible on the next public read. The response setsX-Cache: HIT|MISSfor debug +Cache-Control: public, max-age=60, stale-while-revalidate=300so any CDN in front gets coherent caching.POST /api/v1/public/leads— captures{ blockId, email, name? }, derivespageIdfrom the block (so the public surface can't spoof a different page), upserts on(pageId, email)to dedupe, rate-limited per IP via theauthIpLimitertier.
Authed lead access. The dashboard reads captured leads via:
GET /api/v1/links/pages/:id/leads?cursor=&limit=— cursor-paginated list (newest-first), gated bylinks:leads:read.GET /api/v1/links/pages/:id/leads/export.csv— streams a CSV of all captured leads in 500-row batches; gated bylinks:leads:export.
Templates. Four hardcoded coach archetypes ship in
modules/links/infrastructure/templates/registry.ts (Coach Ejecutivo,
Wellness, Terapeuta, Consultor B2B). They are NOT a Prisma model — at page
creation the applyTemplate use case translates a BlockSeed[] into real
Block rows on positions 10, 20, 30, … and copies the template's theme,
title, and bio onto the page. GET /api/v1/links/templates lists them
(name / description / thumbnail).
Public render. The Next App Router catch-all app/[slug]/page.tsx
SSR-fetches /api/v1/public/pages/by-slug/:slug and renders the page
with eight block components under apps/client/components/public/blocks/
(LeadCapture is the only client component — it owns the form state).
Reserved slugs are filtered server-side via
apps/client/lib/public/reserved-slugs.ts (mirror of the contract's
SLUG_RESERVED augmented with marketing routes) so /admin, /login,
etc. cannot collide with a real route. A dynamic OG image at
app/[slug]/opengraph-image.tsx (next/og) renders a 1200×630 PNG of the
profile for shared links.
Editor is plan 4.
analytics (mibio)
apps/server/src/modules/analytics/
Append-only event store for mibio page traffic. PageView and BlockClick
are immutable rows; DailyRollup is the materialized aggregate
((pageId, date) unique) that powers the editor's analytics tab without
re-scanning raw events.
Ingest path. Two public, unauthenticated POST endpoints under
/api/v1/public/analytics/{view,click} validate a tiny Zod payload and
return 204 before the work runs. The handler then resolves the page
(by slug for views, by blockId for clicks) and inserts a row; bad payloads
/ unknown slugs are logged at warn and never surface to the caller. This
is fire-and-forget by design — the SSR public page emits these beacons
via navigator.sendBeacon (clicks) and fetch({ keepalive: true })
(initial view), so neither blocks render nor gets cancelled on
navigation away to a third-party URL.
Read path. GET /api/v1/links/pages/:id/analytics?range=7d|30d|90d
returns totals + per-block click counts + a dense per-day series. Gated
by links:read and scoped to the owning organization.
Background jobs. Two BullMQ schedules:
analytics-rollup— daily at 03:15 UTC. For every page with activity in the last 36h, materializes yesterday's totals intoDailyRollup. Idempotent via the unique key on(pageId, date).analytics-prune— weekly Sunday 04:00 UTC. Deletes rawPageView+BlockClickrows older than 90 days.DailyRolluprows are kept forever (cheap, one per page per day).
Both processors live in analytics/infrastructure/jobs/ and register in
bootstrap/worker.ts; the schedule installer (scheduleAnalyticsJobs)
runs on every cold start from index.ts and is a no-op when Redis isn't
configured.
Public render ships now (see links above). Editor analytics UI lands in plan 4.