SaaS Starter
Architecture

Bounded contexts

The seven modules shipped in apps/server/src/modules/, what each owns, and what crosses the boundary.

Last updated Jul 19, 2026

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:

  1. Domain events on the in-memory event bus (preferred for fan-out).
  2. HTTP when a stable API contract is wanted.
  3. 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.

Every /organizations/:organizationId/... route pins the URL org to the active org context (requireOrgMatchesPath), and roles can only be granted within the caller's own permissions — see RBAC. The last-owner invariant is enforced atomically in the Prisma adapters (IMembershipRemover, IMembershipRoleChanger).

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.

Reading an organization's entries (GET /api/v1/audit?organizationId=) requires the audit:read permission, not just membership.

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

ConcernMechanism
User signs up → send welcome emailiam publishes user.created → notifications listener enqueues job
Stripe webhook activates subscriptionbilling publishes subscription.activated → audit listener writes log
Endpoint needs to know if subscription is activeMiddleware 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.

Cross-cutting: tenant isolation guard

Two layers keep one organization's rows out of another organization's requests:

  1. Org-scoped repositories. Repositories for org-owned aggregates (roles, memberships, invitations, API keys, webhook endpoints, usage counters) expose findByIdForOrg(id, organizationId) instead of a tenant-blind findById, and their update/delete also take the org. A cross-tenant id simply resolves to null → 404 Not Found. Use cases no longer hand-check resource.organizationId !== input.organizationId.
  2. Prisma tenant guard (runtime net). getPrismaClient() returns a client extended with a query guard: any read/update/delete on a tenant-scoped model (ApiKey, Role, OrganizationMember, Invitation, WebhookEndpoint, UsageCounter, UsageEvent) whose where does not constrain organizationId throws TenantScopeViolationError. Forgetting the scope in new code fails loudly on the first test or request instead of silently leaking data. create is exempt — the schema already requires organizationId. Raw SQL ($queryRaw/$executeRaw) is not intercepted; scope it yourself.

Legitimate cross-tenant access — the webhook delivery worker, token/hash lookups where the org is unknown until the row resolves (invitation accept, API-key auth), GDPR export collection, the usage rollover job — uses getSystemPrismaClient(): an unguarded client that must be injected explicitly per method, so grep prismaSystem audits every bypass.

When you add a new org-owned model to schema.prisma, add its model name to TENANT_SCOPED_MODELS in apps/server/src/infrastructure/db/tenant-guard.ts and give its repository org-scoped signatures.

[!NOTE] Behavior change shipped with this guard: cross-organization access to webhook endpoints, roles, memberships and invitations now returns 404 Not Found (previously 403 Forbidden or 409 Conflict). Not leaking resource existence across tenants is intentional.

Outgoing webhooks: delivery safety

  • URL policy (create and update). HTTPS only in production; http://localhost, 127.0.0.1 and host.docker.internal are accepted only outside production, matched by exact hostname. Credentials in the URL are rejected.
  • SSRF guard at delivery. In production the hostname is resolved first and delivery is refused if any address is loopback, private, link-local or metadata (IPv4 and IPv6). Redirects are never followed: a 3xx counts as a failed delivery. The hostname is resolved once and the connection is pinned to that validated IP (custom lookup), closing the DNS-rebinding window; Host and TLS SNI/certificate validation still use the original hostname.
  • Billing events. subscription.updated / subscription.canceled are delivered to the organization(s) of the billing customer: an org:<id> customer maps straight to that org; a legacy userId customer maps to the orgs that user owns.
  • Idempotency. Enqueue errors are rethrown so the outbox retries; the BullMQ job id and the envelope id derive from the event, so a redelivery does not post twice.

On this page