Skip to content

ARCHITECTURE.md — DocForum ​

Status: Draft v0.1 Last updated: 2026-09-09

Contribution rule: Any PR that changes a data model, a service boundary, or the tech stack must update this file in the same PR, and reflect the essentials in ARCHITECTURE_ESSENTIALS.md. Also update ROADMAP.md status if the change closes/opens roadmap work.

See PRD.md for product scope and ARCHITECTURE_ESSENTIALS.md for the quick-reference version.


1. Guiding principles ​

  1. Modular monolith first, not microservices. One deployable backend, internally organized into bounded modules (auth, doctors, appointments, referrals, consultations, orders, facilities, notifications). Split into services only when a concrete scaling or team-ownership problem demands it — not preemptively (see PRD §8.3).
  2. Orders are append-only. Prescriptions and lab orders are never mutated after issuance. Corrections create a new versioned record referencing the original. This is a hard architectural constraint, not a style preference (clinical/legal safety).
  3. The referral carries state, not a pointer to state. A referral snapshots the intake + consultation notes it's built on, so later edits elsewhere can't silently rewrite what a specialist already saw.
  4. Boring tech, correct concurrency. Availability booking is a correctness problem before it's a scaling problem — solve double-booking with real DB constraints/transactions, not application-level checks.
  5. Build vs. buy for anything not core IP. Video/chat infra, SMS/email delivery, file storage — use managed providers behind an internal interface so they're swappable.

2. Tech stack ​

LayerChoiceWhy
Backend language/runtimeNode.js (TypeScript)Single language across stack, large ecosystem, easy hiring.
Backend frameworkExpress 5 — decided 2026-09-14, resolving the "(or Fastify)" either/orMinimal, unopinionated, matches "modular monolith" — avoid a heavy framework that fights the module boundaries. Express 5's native async-handler error forwarding (rejected promises reach the error middleware without a manual wrapper) was a real factor, not just familiarity.
Request validationZodNot originally listed here — added alongside Phase 1's real request bodies. Schemas live per-module (modules/*/validation.ts), thrown ZodErrors are caught centrally by middleware/error-handler.ts.
Password hashingbcryptjsPure-JS, not a native bcrypt binding — one less native build dependency for a repo with no other native deps yet.
DatabasePostgreSQLRelational integrity is critical (bookings, orders, referrals all have strict FK/state constraints); JSONB available for the flexible parts (intake payload).
ORM / schemaPrismaSchema-as-code, migrations, keeps backend/prisma/schema.prisma as the single source of truth for data models (mirrors §4 below).
AuthJWT access + refresh tokens, role claims (patient | doctor | facility | admin)Simple, stateless for API scaling; refresh rotation for security.
FrontendReact + TypeScript, ViteFast dev loop, standard choice, no framework lock-in beyond React.
Frontend state/dataReact Query (server state) + lightweight store (Zustand) for client-only UI stateAvoids over-centralizing server data into a global store.
Realtime (messaging/notifications)WebSocket gateway (or managed pub/sub, e.g. Pusher/Ably) behind an internal notifications interfaceSwappable; not custom infra from day one.
Video/voice (if/when needed)Third-party embed (Twilio/Daily/Agora) behind an interface, not built in-housePRD §8.3.1 — explicitly flagged as overengineering risk if built custom.
File/result storageObject storage (S3-compatible) for lab result attachmentsNever store clinical files directly in Postgres rows.
Field-level encryptionAES-256-GCM envelope encryption (backend/src/utils/field-encryption.ts, Phase 8 issue #46) for clinical free text (Consultation.notes, Referral.snapshotNotes)Limits blast radius of a leaked DB dump / over-broad read replica — encryption at rest doesn't protect against those. Versioned key-id prefix enables rotation without rewriting every row. Constraint: encrypted fields cannot be searched or indexed — they're write-once/read-decrypt blobs.
Background jobsSimple queue (BullMQ on Redis) for reminders, order-expiry checks, notification deliveryNeeded for FR-15/16 and the "orphaned order" hard question (PRD §8.1.3).
InfraDocker Compose for local dev; containerized deploy target TBD (not decided — see Open Questions in ROADMAP)Keep infra decisions deferred until a real deploy target is chosen; don't overbuild CI/CD for a repo with no code yet.

3. Module boundaries (backend) ​

Each module under backend/src/modules/<name>/ owns its own routes/, controllers/, services/, repositories/. Cross-module access happens through a module's exported service interface only — never reach into another module's repository directly. This is the internal seam that would let us extract a module into its own service later without a rewrite.

  • auth — signup/login, tokens, role checks.
  • users — shared user profile fields common to all roles.
  • patients — patient-specific profile data.
  • doctors — doctor profile, specialties, verification status.
  • facilities — lab/pharmacy accounts, capability tags (what order types they can fulfill).
  • availability — slot generation and holds (the module responsible for preventing double-booking).
  • appointments — booking lifecycle, links patient + doctor + slot + intake.
  • consultations — the record of what happened in a booked appointment (notes, outcome).
  • referrals — referral creation, lifecycle, snapshot of context.
  • prescriptions — prescription issuance and versioning.
  • lab-orders — lab order issuance and versioning.
  • pharmacy-orders — fulfillment-side record for prescriptions at a specific pharmacy (kept distinct from prescriptions itself — see data model note in §4.7).
  • notifications — outbound notification dispatch, provider-agnostic.
  • audit — the append-only AuditEvent trail (Phase 8, issue #43). Owns one write helper, auditService.record(tx, actor, event), that every module calls inside the transaction of the state change it describes (it refuses the root client), and the admin-only GET /admin/audit-events. Controllers build the actor with auditActor(req) (src/middleware/request-id.ts: user id, role, X-Request-Id). Wired today: appointment booking, doctor verification, payment fund/release/refund. Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must call record() in the same transaction when they're built — that wiring is not done here.

4. Data models ​

Canonical source of truth: backend/prisma/schema.prisma. This section is the human-readable mirror — if they drift, the schema file wins and this doc should be corrected in the same PR.

Phase 1 models are now real (User, PatientProfile, DoctorProfile, Specialty, DoctorSpecialty, FacilityProfile, FacilityCapability, AvailabilitySlot, Appointment) — migrated via backend/prisma/migrations/20260914110638_init/. Everything else below (§4.3 onward) is still the Phase 0 structural placeholder. Two intentional deviations from the sketches below, both flagged in the schema file itself: Appointment.intakeId is deferred (nullable-by-omission, not yet a column) since IntakeForm doesn't exist until Phase 2; PatientProfile.guardianUserId is a plain string with no FK relation, since the minor/dependent consent model (§8.2.5 in PRD.md) is still an open question, not resolved here.

4.1 User / Role model ​

User
  id, email, phone, password_hash, role (enum: patient|doctor|facility|admin)
  email_verified_at (nullable — Phase 8 issue #34; null = unverified.
    Backfilled to created_at for pre-existing rows in the
    add_email_verification migration, so accounts from before this phase
    are treated as verified)
  created_at, updated_at

EmailVerificationToken (Phase 8 issue #34)
  id, user_id (FK -> User), token_hash (unique, SHA-256 — raw token never stored)
  expires_at, consumed_at (nullable), created_at
  # Append-only-ish: issuing a new token marks any outstanding one consumed
    ("latest link wins") rather than deleting it. Redemption is a conditional
    UPDATE (consumed_at IS NULL), same single-use shape as §5.1's booking
    guarantee, so two concurrent redemptions can't both win.

PatientProfile (1:1 User)
  id, user_id, full_name, date_of_birth, guardian_user_id (nullable — see PRD edge case: minors)

DoctorProfile (1:1 User)
  id, user_id, full_name, verification_status (enum: pending|verified|rejected)
  specialties: DoctorSpecialty[]

Specialty (controlled vocabulary, admin-managed)
  id, name, slug

DoctorSpecialty (join table)
  id, doctor_profile_id, specialty_id

FacilityProfile (1:1 User)
  id, user_id, name, type (enum: lab|pharmacy), capabilities: FacilityCapability[]

FacilityCapability
  id, facility_profile_id, capability_type (enum: lab_test|medication_dispense), detail (e.g. test category)

4.2 Availability & Appointments ​

AvailabilitySlot
  id, doctor_profile_id, start_time, end_time, status (enum: open|held|booked)
  # Uniqueness/exclusion constraint at DB level on (doctor_profile_id, start_time, end_time)
  # to make double-booking a constraint violation, not an app-logic bug.

Appointment
  id, patient_profile_id, doctor_profile_id, availability_slot_id (FK, unique)
  intake_id (FK -> IntakeForm)
  status (enum: scheduled|completed|cancelled|no_show)
  origin (enum: direct_booking|referral), referral_id (nullable FK -> Referral)
  created_at, updated_at

IntakeForm
  id, patient_profile_id, symptoms (structured: array of {symptom, duration, severity}), free_text_notes
  created_at

4.3 Consultation ​

Consultation
  id, appointment_id (FK, unique)
  notes [ENCRYPTED — Phase 8 issue #46, see §2 field-level encryption row]
  outcome (enum: resolved|referred|follow_up_needed)
  created_at

Encryption note: notes is stored as an AES-256-GCM envelope (v1:<iv>:<tag>:<data>) via backend/src/utils/field-encryption.ts. Because it's encrypted, it cannot be searched or indexed — any future full-text search over consultation notes must decrypt in the application layer or use a separate searchable index, not a Postgres index on this column. Key rotation: backend/scripts/rotate-field-keys.mjs re-encrypts in batches.

4.4 Referral ​

Referral
  id, source_consultation_id (FK)
  referring_doctor_id, target_specialty_id, target_doctor_id (nullable)
  snapshot_intake_id (FK -> IntakeForm, copy reference, immutable)
  snapshot_notes [ENCRYPTED — Phase 8 issue #46] (text copy of the referring consultation notes at time of referral — immutable)
  status (enum: pending|accepted|declined|completed|expired)
  resulting_appointment_id (nullable FK -> Appointment)
  created_at, updated_at

Design note: snapshot_notes and snapshot_intake_id deliberately duplicate data rather than pointing live at Consultation.notes, so a later edit to the original consultation can never silently change what the specialist already acted on (PRD hard question 8.1.4).

4.5 Prescriptions (versioned, append-only) ​

Prescription
  id, consultation_id (FK), patient_profile_id, prescribing_doctor_id
  status (enum: issued|fulfilled|expired|superseded)
  supersedes_prescription_id (nullable, self-FK — corrections create a new row)
  created_at

PrescriptionItem
  id, prescription_id (FK), drug_name, dosage, frequency, duration

4.6 Lab orders (versioned, append-only) ​

LabOrder
  id, consultation_id (FK), patient_profile_id, ordering_doctor_id
  test_type, urgency (enum: routine|urgent), notes
  status (enum: issued|fulfilled|expired|superseded)
  supersedes_lab_order_id (nullable, self-FK)
  created_at

LabResult
  id, lab_order_id (FK, unique), fulfilling_facility_id, result_file_url, summary
  created_at

4.7 Fulfillment (facility-side) ​

FulfillmentRecord
  id, order_type (enum: prescription|lab_order), order_id (polymorphic ref)
  facility_id, status (enum: pending|in_progress|fulfilled|rejected)
  created_at, updated_at

Design note: kept separate from Prescription/LabOrder so a facility's operational status doesn't need write access to the clinical order record itself — it only writes to its own fulfillment row. Reduces blast radius of a compromised/misbehaving facility account.

4.8 Notifications ​

Notification
  id, user_id, type, payload (jsonb), read_at (nullable), created_at

4.9 Auth hardening state (Phase 8) ​

LoginAttempt (issue #35)
  id, scope ('email' | 'ip'), key, failed_count, window_start, last_failed_at
  lock_until (nullable), created_at, updated_at
  # unique (scope, key). One bucket per normalised email and per client IP.
  # Deliberately in Postgres, not process memory: the deployment is a single
  # instance today but must not be designed as one forever, and a restart
  # must not hand an attacker a fresh budget.
  # Counters move only via a single conditional upsert (INSERT ... ON
  # CONFLICT DO UPDATE) — never read-then-write — so N concurrent bad logins
  # are counted as N (integration-tested).
  # The lock is set once per window; hammering a locked account grows the
  # count but never extends the deadline, so a lock is a bounded wait.

User fields added in Phase 8 live in §4.1; the email_verified_at / EmailVerificationToken pair (issue #34) is in §4.1 as well.

4.10 Audit trail (Phase 8, issue #43) ​

AuditEvent
  id, actor_user_id (nullable — null = system), actor_role (nullable Role)
  entity_type ('Appointment' | 'DoctorProfile' | 'PaymentIntent' so far), entity_id
  action (dotted, e.g. 'appointment.booked'), from_state, to_state (nullable)
  request_id (X-Request-Id of the causing request), metadata (jsonb, nullable)
  created_at
  # APPEND-ONLY: no update/delete path in code, and BEFORE UPDATE/DELETE
    (row) + BEFORE TRUNCATE (statement) triggers reject mutation in the
    database. A future retention policy must drop/recreate them in its own
    reviewed migration.
  # No foreign keys on purpose: an ON DELETE action would have to rewrite
    audit rows (forbidden), and RESTRICT would let the trail block account
    lifecycle (e.g. issue #41's purge). Ids are recorded as facts.
  # Written only by audit.record() inside the transaction of the change it
    describes — the event exists iff the change committed.
  # metadata: <= 16 flat scalar keys, strings <= 200 chars; keys that look
    like personal/secret data (name, email, phone, dob, notes, intake,
    password, secret, token, key, body, payload, …) are refused. Never PHI.
  # created_at is stamped by Prisma on the app server per row (ms
    precision); same-millisecond rows are ordered by id.
  # indexes: (entity_type, entity_id, created_at), (actor_user_id,
    created_at), (created_at, id) — the last backs the admin cursor.

Transitions recorded (action: from → to):

  • appointment.booked: null → scheduled (metadata: slot id, doctor profile id, origin)
  • doctor.verification_status_changed: previous → new verification status (read under FOR UPDATE; a no-op set records nothing)
  • payment_intent.funded created → escrowed, payment_intent.released escrowed → released, payment_intent.refunded escrowed → refunded (metadata: order type, amount, escrow id, tx hash, contract id — public on-chain references only). The status write is now conditional on the observed state, so a racing second action gets 409 and records nothing.

5. Key flows (sequence-level, not code) ​

5.1 Booking (concurrency-safe) ​

  1. Client requests open slots for a doctor.
  2. Client attempts to book a specific AvailabilitySlot → backend runs this inside a DB transaction with a row-level lock / conditional update (UPDATE ... WHERE status = 'open'), so a second concurrent booking attempt on the same slot fails atomically instead of racing.
  3. On success, Appointment is created referencing the now-booked slot.

5.2 Referral ​

  1. Doctor closes a Consultation with outcome referred, selects target_specialty (+ optional specific doctor).
  2. Backend creates Referral with an immutable snapshot of intake + notes.
  3. Patient is notified, must accept (PRD OQ-3) before a new Appointment is generated against the target doctor's availability — accepting reuses the snapshot, so the patient does not re-fill intake.
  4. If no specialist is available in that specialty (PRD edge case 8.2.2), referral stays pending and surfaces to the patient as "waiting for a specialist" rather than silently failing — not yet implemented, tracked in ROADMAP.

5.3 Order issuance and fulfillment ​

  1. Specialist issues Prescription and/or LabOrder from a Consultation.
  2. Patient views eligible facilities filtered by FacilityCapability matching the order.
  3. Patient selects a facility → FulfillmentRecord created (pending).
  4. Facility updates its own FulfillmentRecord status; for labs, attaches LabResult.
  5. Order's own status field is updated to fulfilled only via this facility-confirmed path — patient/doctor cannot self-mark it.

6. Hard questions (architecture-level — see PRD §8 for product-level) ​

6.1 What do we think would break? ​

  1. Naive slot booking without the DB-level conditional update in §5.1 — the most likely first bug filed against this repo. Resolved 2026-09-14: implemented exactly as designed in §5.1 — see backend/src/modules/availability/repositories/index.ts (claimOpenSlot) and backend/src/modules/appointments/services/index.ts (bookSlot, the transaction wrapping it). Integration-tested with real concurrency in backend/tests/integration/booking.test.mts.
  2. Referral.target_doctor_id left nullable with no fallback assignment logic — referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. Not designed yet — flag as open architecture gap.
  3. Polymorphic FulfillmentRecord.order_id (§4.7) — polymorphic FKs are a known footgun for referential integrity in Postgres. Needs either two separate tables (PrescriptionFulfillment, LabOrderFulfillment) or a check-constraint + application-level guard. Current schema sketch is a simplification that should be revisited before implementation, not treated as final.
  4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added.
  5. Discovered 2026-10-15 while building email verification (Phase 8, issue #34): the email/SMS provider is still undecided (Phase 6, issue #17), so verification links can't actually be delivered in a hosted environment yet — backend/src/modules/auth/services/email-dispatcher.ts is a no-op seam until a provider is integrated. Until then backend/scripts/mark-email-verified.mjs is the documented out-of-band path for an operator with DB access. Not a reason to skip the guard: the guard is what makes the flow safe once delivery exists, and shipping it locked-off would just mean shipping it untested.

6.2 What edge cases are we missing (architecture-level)? ​

  1. What happens to AvailabilitySlot.status = 'held' if a booking transaction fails/times out mid-flight — is there a TTL to release the hold? (Needed or slots leak into permanent limbo.)
  2. Referral expired status exists in the enum but no expiry job is designed yet — orphaned referrals (PRD 8.1.3 sibling problem).
  3. No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) — AvailabilitySlot currently assumes one calendar per doctor.
  4. No audit-log table — every FR around auditability (PRD §7) currently relies on created_at/updated_at timestamps only, which is not sufficient for "who changed what." A dedicated AuditEvent table is likely needed before this ships to real patients. Foundation resolved 2026-10-17 (issue #43): AuditEvent (§4.10) + audit.record() in the same transaction as the change, append-only at the DB level, wired into booking, doctor verification and payment fund/release/refund. Still open: Phase 3/4 transitions (referrals, prescriptions, lab orders, fulfillment) must be wired as they're built; the admin login-throttle clear (/auth/login-attempts/clear) still only logs to the console; the app connects as the database owner, which could disable the triggers — enforcing append-only against the owner needs a separate least-privilege DB role (deployment work, issue #22).
  5. CSRF on the cookie-authenticated routes — the refresh cookie has to be SameSite=None; Secure (the Pages frontend is a different site — see docs/adr/0003), which means any site the patient visits can make their browser send it to /auth/refresh and /auth/logout. Resolved 2026-10-15 (issue #37): a per-session CSRF token is bound into the refresh token and required in the X-CSRF-Token header, the Origin header is validated against an explicit allowlist, and CORS is now a per-environment allowlist (CORS_ORIGINS) instead of one configured string. The double-submit cookie pattern — the usual answer — is not usable here: a cookie set by this API is a third-party cookie to docforum.github.io, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with no Origin (curl, server-to-server) is allowed through the Origin check but still needs the token. See backend/src/middleware/csrf.ts, and docs/api/README.md for what the client must send. Consequence: refresh cookies issued before this change carry no csrf claim and are rejected, i.e. existing sessions need one re-login.

6.3 What's overengineered (architecture-level, for v1)? ​

  1. Polymorphic fulfillment design (§4.7) may itself be premature abstraction — two small, boring, separate tables might beat one clever polymorphic one at this stage. Flagged as a design smell to revisit, not shipped as-is.
  2. BullMQ/Redis job queue before there's a single job that needs it — fine to stub with a simple cron/interval check initially and introduce a real queue when reminder/expiry logic actually exists.
  3. Designing for horizontal service extraction (module boundary purity, §3) before there's a single reason to split — worth doing lightly (it's cheap discipline), but not worth extra ceremony (e.g., separate repos, separate CI, internal API versioning) yet.

7. Non-functional / cross-cutting ​

  • Testing: unit tests per module (backend/tests/unit), integration tests for booking/referral/order flows specifically because those are the correctness-critical paths (backend/tests/integration).
  • Security — login throttling (Phase 8, issue #35): POST /auth/login is throttled per normalised email and per client IP, in Postgres (LoginAttempt, §4.9): exponential backoff with Retry-After from LOGIN_THROTTLE_BACKOFF_AFTER failures, a temporary lock at LOGIN_THROTTLE_LOCK_AFTER, and a cleanup interval in src/server.ts. Two things this depends on operationally: (1) TRUST_PROXY must be set correctly in production — Render terminates TLS in front of exactly one hop, so TRUST_PROXY=1; leaving it unset makes every request look like Render's proxy (one shared bucket for everyone), and true lets a client spoof X-Forwarded-For for a fresh bucket per request; (2) the throttle deliberately does not reveal whether an account exists — locked, backoff and unknown-account responses are identical, and an absent account still pays the bcrypt comparison cost so response timing doesn't leak either.
  • Migrations: Prisma migrations are the only way schema changes happen — no manual DB edits, ever.
  • Environments: local (Docker Compose), staging, production — staging/production infra choice is an open item (see ROADMAP).

8. Explicit non-decisions (deferred, not forgotten) ​

  • Deployment target (Fly.io / Render / AWS / etc.) — still undecided for production. A Render free-tier instance exists as a preview only (so docforum-web's GitHub Pages preview has a live backend to call) — see docs/adr/0003-render-preview-deployment.md. Explicitly not this decision: no secrets manager, no backups, disposable data, 30-day free-tier DB expiry.
  • Video/voice provider — undecided, PRD OQ-1 must resolve first.
  • Multi-region / i18n — not designed for, not blocked either.

Docs are synced from each repo's own files at build time — see docs/site/scripts/sync-docs.mjs.