A backend service for coaches to log client workouts and read back history and personal records. Built with NestJS + MongoDB (Mongoose), with a BullMQ/Redis queue that precomputes personal records for fast reads at scale.
Scope: features #1 Log, #2 History, #3 Personal Records. (#4 progress chart and #5 insights were descoped; the insights plugin pattern is discussed in the video and in Trade-offs.)
The app lives in everfit-api/.
POST /workouts GET history / prs
(validate + normalize (read path)
+ assign id) │
│ 202 Accepted ▼
▼ WorkoutsService
WRITE_QUEUE ──► Redis ──► WriteProcessor findHistory (cursor)
│ persist (Mongo) getPersonalRecords:
│ then enqueue ───┐ read-model (O(1))
▼ │ └ fallback live $facet
MongoDB ▼
workouts PR_QUEUE ──► Redis ──► PrComputeProcessor
│ $facet recompute
▼
personal_records (read-model)
Both the write and the PR precompute go through BullMQ:
-
POSTvalidates + normalizes + assigns an id, then accepts ontoWRITE_QUEUEand returns 202. A worker persists toworkoutsand triggers PR precompute. (Under test /QUEUE_ENABLED= false, an inline executor persists synchronously instead — no Redis needed, read-after-write holds.) -
The PR worker recomputes all-time PRs into
personal_records, so the common all-time PR query is O(1) (falls back to the live$facetwhen the read-model isn't filled yet). -
One concern per module:
units(weight conversion registry),exercises(muscle-group config),workouts(the three features),queue(write + PR precompute),health. Business logic never readsprocess.env— all config flows throughConfigService. -
Every failure returns one envelope via
AllExceptionsFilter(validation 400 is synchronous).
cd everfit-api
docker compose up --buildBrings up the API (:3000), MongoDB (:27017) and Redis (:6379). The API connects
to the mongo/redis service hosts inside the compose network.
cd everfit-api
npm install
# defaults: MONGODB_URI=mongodb://localhost:27017/everfit, REDIS_HOST=localhost
cp .env.example .env # optional; sensible defaults already baked in
npm run start:dev # watch mode, pretty logsRequires MongoDB on :27017 and Redis on :6379. To run without the queue (no
Redis needed), set QUEUE_ENABLED=false — PRs then always compute live.
| Command | What it does |
|---|---|
npm run start:dev |
Run with hot reload + pretty logs |
npm test |
Unit tests (calculations, utils, filter, queue) |
npm run test:e2e |
Integration tests (mongodb-memory-server) |
npm run seed |
Insert 50,000 entries for one user (SEED_COUNT, SEED_USER_ID overridable) |
npm run build |
Compile (strict TypeScript) |
- Swagger UI: http://localhost:3000/docs (request bodies are pre-filled with valid examples — click Try it out).
- Health: http://localhost:3000/health
All times are UTC, ISO-8601. Weights are stored normalized to kg; the original value + unit are preserved. See error codes for the failure shape.
Validated and normalized at the boundary, then accepted onto a queue: the response is
202 Accepted with the assigned ids; a worker persists the entries (and refreshes the PR
read-model). They become visible via GET /workouts/history shortly after — eventual consistency.
Validation errors still return 400 synchronously (before anything is accepted). Idempotency
and the kg normalization happen on the persist path; check the stored result via GET history.
Query: userId (required), exercise (prefix match), from, to, muscleGroup,
unit (output conversion), limit (1–100, default 20), cursor.
// 200
{
"items": [ /* entries, sets converted to `unit` if given */ ],
"count": 20,
"nextCursor": "eyJkIjoi..." // opaque; pass back as ?cursor= ; null when no more pages
}
// no matches -> 200 { "items": [], "count": 0, "nextCursor": null, "message": "No workouts found..." }Query: userId (required), exercise (required), from, to, unit.
// 200
{
"userId": "665f...011", "exercise": "Bench Press", "unit": "kg",
"current": {
"range": null, // null = all-time
"heaviestSet": { "weight": 110, "reps": 3, "unit": "kg", "date": "..." },
"highestVolumeSet":{ "volume": 900, "weight": 90, "reps": 10, "unit": "kg", "date": "..." },
"bestOneRepMax": { "oneRepMax": 121, "weight": 110, "reps": 3, "unit": "kg", "date": "..." }
},
"previous": { /* same shape, preceding equal-length window — only when from+to given */ },
"deltas": { "heaviestWeight": 10, "highestVolume": 50, "bestOneRepMax": 11.67 }
}- Heaviest = max
weightKg. Volume =reps × weightKg. 1RM = Epleyweight × (1 + reps/30), computed on kg so kg/lb compare fairly. Ties break to the earliest date (first time achieved).
{ "status": "ok", "info": { "mongodb": { "status": "up" } }, "details": { ... } } // 503 if Mongo is downEvery failure returns one envelope:
{ "statusCode": 400, "error": "Bad Request", "message": "Validation failed",
"details": ["reps must not be less than 1"], // optional (validation array)
"timestamp": "2026-06-09T...Z", "path": "/workouts" }| Code | When |
|---|---|
| 400 | Validation (bad unit, reps<1, weight<0, empty sets/entries, non-ObjectId userId, ambiguous/offset-less date), malformed cursor |
| 404 | Unknown route |
| 409 | Duplicate-key conflict |
| 500 | Unexpected error — generic message, real error logged (never leaked) |
WorkoutEntry { userId: ObjectId, exerciseName, exerciseNameLower,
date: Date (UTC), sets: [ { reps, weight, unit, weightKg } ],
idempotencyKey?, createdAt, updatedAt }
- Embedded sets (subdocuments): sets are always read/written with their entry and are few — no separate collection needed.
weightKgstored alongside the originalweight+unit: normalize-on-write so queries/aggregations compare across units without losing what the user entered.exerciseNameLower: search and indexes use the normalized name, never mixed case.- Muscle group is not stored — resolved live from a config map, so re-tagging an exercise never leaves persisted rows stale.
Indexes
| Index | Serves |
|---|---|
{ userId, date:-1 } |
History newest-first / date range |
{ userId, exerciseNameLower, date:-1 } |
Exercise filter + per-exercise PR scans |
{ userId, date:-1, _id:-1 } |
Cursor pagination (keyset, _id tie-break) |
{ userId, idempotencyKey } unique partial |
Idempotency (only when a key is present) |
PersonalRecord { userId, exerciseNameLower, heaviest, volume, oneRepMax } // unique {userId, exerciseNameLower}
Maintained asynchronously by the PR-compute worker so the all-time PR query is O(1).
- Pagination = keyset (cursor), not offset — stable and fast at 50k+ rows; the
(date, _id)cursor never skips/dupes same-day entries. - Idempotency via the unique partial index + upsert + duplicate-key catch: concurrent identical submits (with a key) collapse to one entry; keyless submits stay independent (they are legitimately separate logs).
Async writes (202 + eventual consistency). POST validates + normalizes synchronously, then
accepts the batch onto a queue and returns 202 with the assigned ids; a worker persists. This
decouples write load from the request and absorbs bursts. Trade-off: a just-accepted entry is not
instantly readable (it appears within ~one worker cycle), and the client gets ids rather than the
persisted body. For a cheap indexed insert this is a deliberate scalability choice, not a latency
win per request — for a simpler contract, the persist handler can also be called inline (it already
is, under QUEUE_ENABLED=false).
Timezone. The API contract is UTC, ISO-8601. A datetime without an offset is
rejected (it would otherwise be parsed in the server's local timezone — different
instant on host vs Docker vs CI). Clients send date-only (YYYY-MM-DD, treated as UTC
midnight) or a datetime with Z/offset.
userId validated as a Mongo ObjectId. This couples the public contract to the
storage ID format — accepted for the monolith (it is an ObjectId, fail-fast, and it
prevents a 500 from new ObjectId(badInput)). If user identity moves to its own service
or a non-Mongo store, treat userId as an opaque id and push ObjectId conversion
into a persistence adapter (also map the resulting BSONError → 400).
10,000 concurrent coaches — in priority order (the DB is the bottleneck before the stateless app tier is):
- Connection pooling — bounded Mongo pool per instance; scale instances behind a LB.
- Read replicas — route history/PR reads to secondaries; writes to primary.
- Shard
workoutsonuserId— every query is user-scoped, so reads are targeted (no scatter-gather); existing indexes already lead withuserId. - PR read-model (already built) — async precompute keeps the hot PR read O(1); extend with Redis caching / TTL if needed.
- Backpressure — gateway rate-limits per coach; cap bulk payload size.
- Observability — structured pino logs (per-request id) + p99 metrics + tracing.
Queue, then microservices. Both writes and PR precompute already run through BullMQ. The next
step at scale is an insights worker (each insight type a consumer = the plugin pattern).
Splitting into microservices (separate Ingestion / Query / Insights services) comes only when their
scaling profiles diverge — the current module seams (workouts, queue, units, exercises) map
directly onto those future services.
- Unit (
npm test): every calculation + pure util — unit conversion, Epley 1RM, cursor encode/decode, regex-injection escaping, muscle-group resolution, the error filter (incl. no-500-leak), the queue producers + read-model mapping, UTC date parsing. - Integration (
npm run test:e2e, mongodb-memory-server): each endpoint, plus user isolation, multi-set PR selection, a real concurrency race (N simultaneous identical submits collapse to one entry), the timezone contract, and the one error shape. - Test design over coverage %: meaningful edge cases, not line-count.
See AI_WORKFLOW.md for how this was built with AI assistance.