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 updateROADMAP.mdstatus 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
- 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).
- 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).
- 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.
- 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.
- 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
| Layer | Choice | Why |
|---|---|---|
| Backend language/runtime | Node.js (TypeScript) | Single language across stack, large ecosystem, easy hiring. |
| Backend framework | Express 5 — decided 2026-09-14, resolving the "(or Fastify)" either/or | Minimal, 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 validation | Zod | Not 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 hashing | bcryptjs | Pure-JS, not a native bcrypt binding — one less native build dependency for a repo with no other native deps yet. |
| Database | PostgreSQL | Relational integrity is critical (bookings, orders, referrals all have strict FK/state constraints); JSONB available for the flexible parts (intake payload). |
| ORM / schema | Prisma | Schema-as-code, migrations, keeps backend/prisma/schema.prisma as the single source of truth for data models (mirrors §4 below). |
| Auth | JWT access + refresh tokens, role claims (patient | doctor | facility | admin) | Simple, stateless for API scaling; refresh rotation for security. |
| Frontend | React + TypeScript, Vite | Fast dev loop, standard choice, no framework lock-in beyond React. |
| Frontend state/data | React Query (server state) + lightweight store (Zustand) for client-only UI state | Avoids 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 interface | Swappable; not custom infra from day one. |
| Video/voice (if/when needed) | Third-party embed (Twilio/Daily/Agora) behind an interface, not built in-house | PRD §8.3.1 — explicitly flagged as overengineering risk if built custom. |
| File/result storage | Object storage (S3-compatible) for lab result attachments | Never store clinical files directly in Postgres rows. |
| Field-level encryption | AES-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 jobs | Simple queue (BullMQ on Redis) for reminders, order-expiry checks, notification delivery | Needed for FR-15/16 and the "orphaned order" hard question (PRD §8.1.3). |
| Infra | Docker 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 fromprescriptionsitself — see data model note in §4.7).notifications— outbound notification dispatch, provider-agnostic.audit— the append-onlyAuditEventtrail (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-onlyGET /admin/audit-events. Controllers build the actor withauditActor(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 callrecord()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_at4.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_atEncryption 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_atDesign 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, duration4.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_at4.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_atDesign 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_at4.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 underFOR UPDATE; a no-op set records nothing)payment_intent.fundedcreated → escrowed,payment_intent.releasedescrowed → released,payment_intent.refundedescrowed → 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 gets409and records nothing.
5. Key flows (sequence-level, not code)
5.1 Booking (concurrency-safe)
- Client requests open slots for a doctor.
- 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. - On success,
Appointmentis created referencing the now-bookedslot.
5.2 Referral
- Doctor closes a
Consultationwith outcomereferred, selectstarget_specialty(+ optional specific doctor). - Backend creates
Referralwith an immutable snapshot of intake + notes. - Patient is notified, must accept (PRD OQ-3) before a new
Appointmentis generated against the target doctor's availability — accepting reuses the snapshot, so the patient does not re-fill intake. - If no specialist is available in that specialty (PRD edge case 8.2.2), referral stays
pendingand 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
- Specialist issues
Prescriptionand/orLabOrderfrom aConsultation. - Patient views eligible facilities filtered by
FacilityCapabilitymatching the order. - Patient selects a facility →
FulfillmentRecordcreated (pending). - Facility updates its own
FulfillmentRecordstatus; for labs, attachesLabResult. - Order's own
statusfield is updated tofulfilledonly 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?
- 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) andbackend/src/modules/appointments/services/index.ts(bookSlot, the transaction wrapping it). Integration-tested with real concurrency inbackend/tests/integration/booking.test.mts. Referral.target_doctor_idleft 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.- 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. - JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added.
- 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.tsis a no-op seam until a provider is integrated. Until thenbackend/scripts/mark-email-verified.mjsis 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)?
- 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.) - Referral
expiredstatus exists in the enum but no expiry job is designed yet — orphaned referrals (PRD 8.1.3 sibling problem). - No schema support yet for a doctor working across multiple facilities/locations (PRD edge case 6) —
AvailabilitySlotcurrently assumes one calendar per doctor. - No audit-log table — every FR around auditability (PRD §7) currently relies on
created_at/updated_attimestamps only, which is not sufficient for "who changed what." A dedicatedAuditEventtable 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). - CSRF on the cookie-authenticated routes — the refresh cookie has to be
SameSite=None; Secure(the Pages frontend is a different site — seedocs/adr/0003), which means any site the patient visits can make their browser send it to/auth/refreshand/auth/logout. Resolved 2026-10-15 (issue #37): a per-session CSRF token is bound into the refresh token and required in theX-CSRF-Tokenheader, theOriginheader 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 todocforum.github.io, so the frontend's JavaScript can never read it; the token travels in the login/refresh response body instead. A request with noOrigin(curl, server-to-server) is allowed through the Origin check but still needs the token. Seebackend/src/middleware/csrf.ts, anddocs/api/README.mdfor what the client must send. Consequence: refresh cookies issued before this change carry nocsrfclaim and are rejected, i.e. existing sessions need one re-login.
6.3 What's overengineered (architecture-level, for v1)?
- 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.
- 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.
- 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/loginis throttled per normalised email and per client IP, in Postgres (LoginAttempt, §4.9): exponential backoff withRetry-AfterfromLOGIN_THROTTLE_BACKOFF_AFTERfailures, a temporary lock atLOGIN_THROTTLE_LOCK_AFTER, and a cleanup interval insrc/server.ts. Two things this depends on operationally: (1)TRUST_PROXYmust be set correctly in production — Render terminates TLS in front of exactly one hop, soTRUST_PROXY=1; leaving it unset makes every request look like Render's proxy (one shared bucket for everyone), andtruelets a client spoofX-Forwarded-Forfor 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) — seedocs/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.