Skip to Content
Living documentation — last reviewed 2026-05-28
ArchitectureAPI architecture

API architecture

The API is a NestJS 11 application at apps/api, port 3001. It’s the single source of business truth for the platform. The Next.js web app, the future WhatsApp bot, and the future native client all consume the same REST surface.

Bootstrap entry point: apps/api/src/main.ts. App module: apps/api/src/app/app.module.ts.

Bootstrap pipeline (main.ts)

  1. ./instrument runs first — Sentry is initialized before NestFactory builds the app (required for the Sentry SDK to capture early boot errors).
  2. validateEnv() — schema-checks env at startup (skipped under NODE_ENV=test). Source: apps/api/src/config/env.schema.ts.
  3. NestFactory.create with bufferLogs: true and rawBody: true (Svix webhook signature verification needs the raw body).
  4. Pino logger swapped in via app.useLogger(app.get(Logger)).
  5. Helmet + compression middleware.
  6. CORS — origin allow-list from ALLOWED_ORIGINS, with *.subdomain suffix matching. Exposes sentry-trace, baggage, and x-request-id.
  7. Global ValidationPipe with whitelist: true, transform: true.
  8. Socket.IO adapter — Redis adapter when REDIS_URL is set (multi-instance scaling), otherwise default in-memory.
  9. Swagger mounted at /api/docs with bearer-auth scheme.
  10. app.listen(PORT || 3001).

Module organization

Every feature is a NestJS module. The full list, from app.module.ts:

ModuleRole
DatabaseModuleGlobal — provides DATABASE_CLIENT (pg Pool + Drizzle).
AuthModuleGlobal — provides CLERK_CLIENT (Clerk backend SDK).
RedisModuleGlobal — provides REDIS (ioredis client).
R2ModuleCloudflare R2 client + presigned URL helpers.
UsersModuleUser CRUD, /users/me, profile completion, national-ID encryption.
OrganizationsModuleOrg CRUD, settings.
MembershipsModuleMembership lifecycle, invitations, role management.
InvitationsModule (in memberships)Invitation tokens + Clerk-ticket flow.
ProgramsModulePrograms (`feed
ProgramTemplatesModuleReusable program structures (FIT-74).
CoursesModuleCourse config + entitlements.
LocationsModulePer-org physical locations.
ClassTypesModuleClass type templates.
ClassSessionsModuleScheduled session instances, bulk actions.
BookingsModuleMember bookings, waitlist, check-in.
ExercisesModuleExercise library + canonicalization.
WorkoutsModuleWorkouts + workout movements + snapshots.
WorkoutAssignmentsModuleCoaching-grid assignment cells.
WorkoutResultsModuleMember result entries + PR detection.
DailyProgrammingModuleDaily WOD posting.
PlatformTiersModuleTier metadata + the PlatformTierGuard and @RequiresFeature decorator.
PlatformBillingModuleFitKit’s B2B Cardcom billing of orgs.
PlansModulePlan SKUs (subscription / class_pack / drop_in / course).
PaymentsModuleProvider adapters (Cardcom, Morning, iCredit, etc.), transactions, refunds.
SubscriptionsModuleMember subscription lifecycle (pending → active → past_due → debt → cancelled).
MessagesModule1:1 + workout-thread messaging.
AnnouncementsModuleOrg-wide broadcasts.
NotificationsModuleIn-app + email notifications.
PushNotificationsModuleExpo push fan-out via BullMQ.
EventTrackingModuleServer-side PostHog event ingestion.
TasksModuleOperator to-do queue (lead follow-up, manual refund, cancellation review, etc.).
WebhooksModuleInbound webhook receivers (Clerk, payment providers).
LeadsModule, OrganizationLeadsModuleLead capture + pipeline.
BodyMetricsModule, ProgressPhotosModuleMember body data.
GoalsModuleMember goals (body metric / exercise PR).
LegalModuleVersioned legal docs + consent.
FormsModuleForms engine (compliance + check-in).
MetricSetsModuleMetric definitions + per-template metric sets.
EmbeddingsModule, AiModule (Spotter)Embeddings + agent runtime.
AnalyticsModule, InsightsModuleOperator dashboards.
AdminModuleInternal-only admin endpoints + cost dashboards.
ImportModule, ExportModuleArbox/CSV imports, CSV exports.
UploadsModulePresigned upload endpoints (R2).
BullBoardModuleMounts the BullMQ admin UI.
TestingModuleTest-only. Mounted only when NODE_ENV !== 'production' && TEST_HOOKS_ENABLED === 'true'. Exposes /testing/* endpoints (DB seed, cron triggers).

Global guards / interceptors / filters

Wired in app.module.ts’s providers:

APP_FILTER AllExceptionsFilter APP_GUARD AuthGuard (1st — verifies Clerk token, loads DB user) APP_GUARD PlatformTierGuard (2nd — enforces @RequiresFeature(...)) APP_GUARD ThrottlerGuard (3rd — 60 req / 60s default) APP_INTERCEPTOR LoggingInterceptor (Pino logging + Sentry scope tagging) APP_INTERCEPTOR CacheControlInterceptor

AuthGuard (apps/api/src/auth/auth.guard.ts)

  • Default: protects every route.
  • Opt out with @Public().
  • Reads Authorization: Bearer <token>, verifies with Clerk’s verifyToken, populates request.auth = { userId, sessionId, user } where user is the loaded DB row (or null if Clerk user has no DB row yet).
  • Test bypass. When TEST_AUTH_BYPASS=true (non-prod only), x-test-user-id, x-test-session-id, x-test-role headers stand in for a real token. Used by E2E tests and integration tests. Disabled in production by construction.
  • Tags Sentry user on success; adds a Sentry breadcrumb on token verification failure.

PlatformTierGuard (apps/api/src/platform-tiers/platform-tier.guard.ts)

  • Inspects @RequiresFeature(...) metadata.
  • Resolves the request’s org via request.params.orgId, looks up platform_tier once.
  • Checks each required feature against PLATFORM_TIER_MAP (in @fitkit/shared). Throws ForbiddenException on missing entitlement.
  • Caches request.platformTier + request.platformTierConfig for downstream handlers to use.

ThrottlerGuard

ThrottlerModule.forRoot({ throttlers: [{ ttl: 60000, limit: 60 }] }) — 60 requests per 60s per IP by default. Health endpoint opts out via @SkipThrottle().

LoggingInterceptor (apps/api/src/app/logging.interceptor.ts)

  • Captures request id, user id, org id (from :orgId param) into log + Sentry scope.
  • Logs warn on 4xx, error on 5xx, and warn on >800ms responses tagged SLOW.
  • Reflects requestId back as X-Request-Id response header.

CacheControlInterceptor (apps/api/src/common/interceptors/cache-control.interceptor.ts)

  • Only acts on GETs.
  • Default Cache-Control: private, no-cache. Override with @CacheControl('public, max-age=60') or opt out entirely with @CacheControl(null) (which emits no-store).

AllExceptionsFilter (apps/api/src/app/http-exception.filter.ts)

  • Extends Sentry’s SentryGlobalFilter.
  • Normalises the response body to { ...originalBody, statusCode, requestId, timestamp }.

DI tokens

The two tokens you’ll inject 90% of the time:

TokenModuleResolves toImport path
DATABASE_CLIENTDatabaseModule (global)Drizzle DbClient from @fitkit/dbapps/api/src/database/database.module.ts
CLERK_CLIENTAuthModule (global)@clerk/backend ClerkClientapps/api/src/auth/auth.module.ts

Other commonly-injected tokens:

TokenModuleResolves to
REDISRedisModule (global)ioredis client
@InjectQueue('<name>')Per-feature queue moduleBullMQ Queue instance

Usage:

constructor( @Inject(DATABASE_CLIENT) private db: DbClient, @Inject(CLERK_CLIENT) private clerk: ClerkClient, ) {}

Decorators

DecoratorDefined inWhat it does
@Public()apps/api/src/auth/public.decorator.tsSkip AuthGuard on this route.
@CurrentUser('userId' | 'user' | ...)apps/api/src/auth/current-user.decorator.tsInject the request’s auth payload (or a field of it).
@RequiresFeature(...)apps/api/src/platform-tiers/requires-feature.decorator.tsGate the route on platform-tier features.
@CacheControl(directive | null)apps/api/src/common/interceptors/cache-control.interceptor.tsOverride default Cache-Control.
@SkipThrottle()NestJS @nestjs/throttlerOpt this route out of the rate limiter.

Response convention

All responses wrap data:

// Single { "data": { "id": "...", "name": "...", "status": "active" } } // Paginated { "data": [ ... ], "meta": { "total": 120, "page": 1, "pageSize": 20 } }

Shape contracts are Zod schemas in @fitkit/shared (e.g. UserResponse, ApiResponse<T>, PaginatedResponse<T>).

Explicit state fields, not booleans. Where a status has multiple meaningful values, the response carries the enum value. The web client never re-derives state from inferred booleans — that’s what makes the API consumable by non-web clients without duplication.

Swagger

SwaggerModule.setup('api/docs', app, document) in main.ts. Bearer auth declared via DocumentBuilder().addBearerAuth(). Per-route decoration uses @ApiTags('Feature'), @ApiOperation, @ApiResponse — standard NestJS Swagger conventions.

WebSockets

Single Socket.IO server, multi-instance-safe via the Redis adapter (@socket.io/redis-adapter). Origin is locked to FRONTEND_URL (credentials allowed).

Concrete gateways live inside their feature modules — search for files ending in .gateway.ts. Typical use: 1:1 messaging delivery, typing indicators, announcement fan-out, Spotter streaming.

Background jobs (BullMQ)

BullModule.forRootAsync is wired globally in app.module.ts and connects to REDIS_URL. Each feature module that owns a queue calls BullModule.registerQueue({ name: '...' }) and the matching service processor (@Processor('...') class) handles jobs.

Modules registering queues today (greppable):

  • apps/api/src/exercises/
  • apps/api/src/ai/embeddings/
  • apps/api/src/programs/
  • apps/api/src/workouts/
  • apps/api/src/export/
  • apps/api/src/import/
  • apps/api/src/push-notifications/
  • apps/api/src/admin/services/admin-queues.service.ts (admin inspection)

The Bull-Board UI is at apps/api/src/bull-board/ and gates access via membership-admin auth.

Cron jobs

NestJS @Cron decorators on services. Master switch: CRONS_ENABLED=true (default false). Without the flag, every @Cron-decorated handler bails immediately in crons-enabled.ts (apps/api/src/common/crons-enabled.ts). This keeps dev DBs from staying warm 24/7 and burning Neon free-tier minutes.

Known cron users: billing retry, pre-charge reminders, no-show sweep, AI storage-snapshot.

In-process event bus

EventEmitterModule.forRoot() is enabled globally. Subsystems emit events, others listen via @OnEvent. Canonical example: Forms onboarding hook — apps/api/src/forms/forms.service.ts listens for membership-activation events and auto-issues compliance forms. Used to break import cycles between subsystems that otherwise would have to import each other.

Webhook security

rawBody: true is set on the Nest app so Svix can verify the Clerk webhook signature against the unparsed body. The Clerk webhook controller (apps/api/src/webhooks/clerk-webhook.controller.ts) verifies svix-id, svix-timestamp, and svix-signature headers with CLERK_WEBHOOK_SECRET before acting on user.created, user.updated, user.deleted events.

  • auth.md — Clerk verification, user sync, multi-org.
  • database.md — Drizzle schema layout, migrations.
  • observability.md — Pino + Sentry + PostHog wiring details.