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.

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

ConcernMechanism
User signs up → send welcome emailiam publishes user.creatednotifications listener enqueues job
Stripe webhook activates subscriptionbilling publishes subscription.activatedaudit listener writes log
Endpoint needs to know if subscription is activeMiddleware reads tenancybilling (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 null404 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.

On this page