SaaS Starter
Architecture

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:

  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.

catalog — Game-server directory

apps/server/src/modules/catalog/ owns the game-server directory: servers, claims (owner verification), and the cold-start seed importer.

  • Domain: ServerAggregate, Claim, value objects for country/language/slug.
  • Application: SubmitServer, VerifyClaim, GetServer, ExpireStaleClaims, and SeedServers (cold-start orchestrator). The hasOwner projection (application/projections/server-claim-status.ts) is the single source of truth for the "Reclamalo" banner — UI code calls it instead of checking ownerId === null inline.
  • Infrastructure: Prisma repos, NanoidSlugGenerator, and the seed importer adapters under infrastructure/seed/SteamServerListFetcher (Rust/CS2/ARK/ARK SA), FiveMServerListFetcher, MinecraftCsvFetcher, GeoIpResolver (LATAM filter), and a 1-req/s createThrottle.
  • Cold-start entrypoint: apps/server/scripts/seed-servers.ts (driven by bun run --cwd apps/server seed:servers --game=<...>). Inserts rows with status=UNCLAIMED, ownerId=null so they surface a claim banner.
  • Daily refresh: the catalog.seed-refresh BullMQ tick re-runs Rust importing at 05:00 UTC, gated on SEED_REFRESH_ENABLED=true. See Queue a background job.

Owner dashboard (Plan 5)

The owner-facing dashboard lives inside catalog and consumes telemetry read models (StatsSnapshot, UptimeWindow, RankingScore) via SQL aggregation rather than as a cross-context port.

  • Use cases: ListMyServers, GetServerStats, UpdateServerSettings, UploadServerBanner, SoftDeleteServer. All owner-guarded.
  • HTTP surface (under /api/v1/catalog):
    • GET /servers/sitemap — public. Flat list of every visible (ACTIVE+UNCLAIMED) server's slug, game, and lastModifiedAt (= GREATEST(createdAt, verifiedAt, hiddenAt), since the Server model has no updatedAt). Consumed by Next's app/sitemap.ts to emit one <url> per server detail page. Cached on the client side for 1h.
    • GET /me/servers — list with denormalized status / 7d uptime / avg players / active boost.
    • GET /servers/{id}/stats?range=7d|30d — hourly bucketed series (uptime / avg players / avg latency). Aggregated via date_trunc('hour', ...) in SQL — never returns raw 21k-row snapshot arrays.
    • GET /servers/{id}/settings — owner-only. Webhook URL is masked: response carries hasNotificationWebhook: boolean, not the URL.
    • PATCH /servers/{id}/settings — allowlist of name, description, country, language, discordUrl, websiteUrl, tags, notificationWebhookUrl.
    • POST /servers/{id}/banner — multipart upload, content-addressed key servers/{id}-{sha256:16}.{ext} (immutable cache safe).
    • DELETE /servers/{id} — soft delete: status=SUSPENDED + deletedAt (NOT the DELETED enum, which is reserved for support hard-delete).
  • Downtime alerts: OnServerStatusChange listener subscribes to telemetry.ServerDownDetected and telemetry.ServerOnlineDetected on the boilerplate event bus. Applies 5-min flap debounce + dispatch-key idempotency via the ServerAlertState Prisma model. Dispatches a Discord embed via UndiciDiscordWebhookNotifier (fetch + AbortSignal timeout). Failures log and persist state anyway — the listener never throws back into the event bus. Env: DISCORD_WEBHOOK_TIMEOUT_MS (default 5000) and DISCORD_FLAP_DEBOUNCE_MS (default 300000).
  • Frontend: apps/client/app/(app)/dashboard/servers/ (list, panel, settings). Charts use recharts (already a Plan 3 dep). I18n keys under ownerDashboard.* namespace.

telemetry — Polling pipeline & ranking

apps/server/src/modules/telemetry/ owns the polling pipeline and the denormalized RankingScore read model.

  • Domain: StatsSnapshot, UptimeWindow, RankingScore entities; PollResult, RankingFormula, percentile-rank helpers.
  • Application: PollServer, RollupHourly, RecomputeRanking use cases. Emits telemetry.ServerOnlineDetected, telemetry.ServerDownDetected, telemetry.ServerLikelyDeleted domain events.
  • Infrastructure: gamedig adapter (IServerProber), Prisma repos, PrismaBoostMultiplierReader (cross-context read of catalog's Boost).
  • Scheduling: repeatable BullMQ job schedulers on three queues — telemetry.poll (every 2 min), telemetry.poll-slow (every 10 min, after 3 consecutive failures), telemetry.poll-dead (every 1h, after 6 consecutive failures). Crons: telemetry.rollup (hourly), telemetry.ranking (every 15 min). DLQ: telemetry.dlq (parse errors).
  • Ranking formula: boostedScore = (0.6 × players_pctile + 0.3 × uptime_pct + 0.1 × votes_pctile) × multiplier with guardrail avgPlayers7d ≥ 5 (otherwise multiplier is ignored).

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.

Public directory routes (Next.js)

The public surface lives under apps/client/app/(public)/:

  • / — landing with top 5 servers per game (ISR 60s)
  • /<game> — full listing, ordered by RankingScore.boostedScore DESC (ISR 60s)
  • /<game>/<country> — long-tail SEO listing (ISR 60s)
  • /servers/<slug> — detail with charts and JSON-LD (ISR 300s)

ES and PT mirrors live under apps/client/app/es/(public)/... and apps/client/app/pt/(public)/... following the boilerplate's segment-duplication convention. Hreflang alternates are declared in generateMetadata.

Server-Component fetchers (apps/client/app/_directory/fetchers.ts) hit the catalog HTTP API via fetch() with Next.js cache tags (directory:listing:<game>, directory:server:<slug>). The catalog/telemetry layer is responsible for revalidating these tags from event listeners when listing data changes.

Any server with an active Boost (any tier) renders a "Patrocinado" badge inline on its listing row — boosted servers do not occupy reserved slots; they rise organically because the multiplier inflates boostedScore.

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.

On this page