Architecture Overview
Canvas-first microservice architecture. Canvas FHIR is the medical-record database of record; every service we build is a single-purpose sidecar around it. One Canvas instance serves YourEra DTC and every GuideGLP B2B client via FHIR-native multi-tenancy.
The Principle
Canvas FHIR is the medical-record database. Every clinical fact lives in Canvas. Our services are sidecars around it.
For everything else — payments, shipping logistics, tenant branding, notification
delivery, schedulers, compliance archives — we build lean single-purpose sidecar
services that hold the minimum state needed to do their job and reference Canvas for
clinical truth. One Canvas instance, many Organizations: YourEra DTC is a FHIR
Organization; each GuideGLP client (Biologics, LCMC, Sleep Corner, etc.) is
also a FHIR Organization. Every Patient has a
managingOrganization pointing to one of them.
The decision rule when designing anything new:
Would a HIPAA auditor expect this data in an EMR? Then Canvas owns it.
Would a Stripe auditor, FedEx tracking system, or CMS-style operational dashboard expect this data? Then a sidecar owns it.
No caching Canvas data "just in case." No storing clinical fields in sidecars. No cross-database joins. Read canvas-first-principles for the full doctrine.
System Diagram
Canvas-first service mesh. Every service is a sidecar around Canvas FHIR. Portals
(patient + admin) read/write Canvas directly via the @yourera/canvas-client
shared library; sidecars either write Canvas at intake completion (intake-gateway),
via the saga (prescription-orchestrator writing MedicationDispense + Task), or
never (most). Arrows show the primary data flow; async event traffic flows through the
central event bus (SQS + EventBridge) with outbox delivery guarantees.
For the architectural doctrine behind this diagram (why Canvas-first, what can and cannot live in a sidecar, what we explicitly reject) see canvas-first-principles.
Pipeline Walkthrough
The full patient journey from intake to delivered Rx under the Canvas-first architecture.
Patient reaches their org's intake site (e.g., intake.biologics.com/start-online-visit/glp-1).
Intake Gateway resolves the org via
Organization Service, creates an intake_sessions row,
and emits contact.captured once email is collected.
Notification Service creates a contacts row
— the lead now exists.
Patient completes all intake steps. On final submit, Intake Gateway
atomically writes Canvas resources via canvas-client:
Patient (with managingOrganization set), Observation
(vitals), Condition (history), AllergyIntolerance,
MedicationStatement, QuestionnaireResponse, Consent,
and DocumentReference (ID scans). In the same logical transaction,
Patient Service writes the patient_directory row
+ the INSERT-only intake_snapshots compliance record.
Commerce Service creates a subscription + order #1 based on
the plan selected. Payment Service creates a Stripe
SetupIntent (payment method collection) and then a PaymentIntent for the initial charge
— quarterly and 6-month plans are charged fully upfront; monthly
plans charge per fill. On successful capture, order → paid,
subscription → active.
Canvas Task is routed to eligible providers (via Physician Registry
+ the Canvas prescription_queue plugin, now org-aware). Provider reviews H&P in
Canvas, approves, and signs the prescription. Canvas emits
MedicationRequest.status = active. If the provider rejects with a free-form
note, the MR stays draft or cancelled — no saga runs, no
charge reversed until admin triggers refund.
Webhook Receiver ingests Canvas's webhook and emits
canvas.medication_request.activated on the internal event bus.
Prescription Orchestrator creates an
rx_fill_sagas row and runs the state machine:
preflight (allergy, contraindication, org status) →
payment (skipped if order is prepaid per plan cadence) →
pharmacy submission.
Orchestrator asks Pharmacy Router for a route via
getRoute(org, state, medication). Router reads the live
pharmacy_routes table: Galleria (GMP) if licensed in the patient's
state, else Strive. Routing is re-evaluated every fill — a patient
who went to Strive for fill #1 automatically routes to GMP for fill #2 if GMP adds a
license in between. No per-patient routing pin. Pharmacy dispenses; pharmacy-router
writes Canvas MedicationDispense on callback.
Shipping Service generates a FedEx 2Day Express label (fetching patient address live from Canvas — never cached) and delivers it to the pharmacy via signed S3 URL. The pharmacy manages cold-chain packaging (their domain, not ours). As carrier events flow in, Notification Service sends templated updates to the patient (shipped, out-for-delivery, delivered) using the org's verified sender identity.
Refill Scheduler maintains a lean materialized view of
every active MedicationRequest, fed by Canvas webhooks. When
next_fill_due_at ≤ now, it emits refill.due. The orchestrator
picks up, creates a new saga, and runs the full pipeline again. Prepaid-plan refills
skip the charge stage (money already collected); pay-as-you-go refills generate a new
PaymentIntent. A nightly reconcile worker diffs the view against Canvas to catch any
webhook drops.
Service Catalog
patient_directory maps MRN ↔ canvas_patient_id ↔ stripe_customer_id ↔ clerk_id ↔ organization_id. intake_snapshots is INSERT-only compliance. Two tables. No clinical data.intake_sessions for resume + lead capture; never a staging blob.MedicationRequest + MedicationDispense webhooks. Fires refill.due; never writes Canvas directly. Nightly safety-net reconciler.pharmacy_routes. Writes Canvas MedicationDispense on dispense callback.physician_org_memberships in organization-service.Inter-Service Communication
Two communication styles, picked per use case:
- Async events (primary). Outbox pattern from each producer; SQS/EventBridge delivers at-least-once. Consumers must be idempotent. Used for everything that doesn't need a synchronous response.
- Direct HTTP (fallback). Service-to-service JWT signed with a shared secret per service pair. Used when the caller genuinely needs the response now (e.g., admin-portal calling commerce to create a refund).
Event Naming Convention
{domain}.{entity}.{past-tense}
Examples: intake.completed, patient.created,
payment.intent_succeeded, rx.fill_completed,
refill.due, pharmacy.dispense_confirmed,
shipping.delivered.
Call Graph (HTTP, when sync is required)
Async Event Examples
Database Topology
Each service owns its own Postgres database. No cross-service DB access; services communicate only via APIs and events. No DB-level foreign keys cross service boundaries — UUIDs only, app-enforced.
| Service | Database | Key Tables |
|---|---|---|
| Organization | organization_service | organizations, organization_branding, organization_portal_config, organization_notification_config, organization_custom_domains, admin_users, admin_org_memberships, physician_org_memberships |
| Patient | patient_service | patient_directory, intake_snapshots |
| Intake Gateway | intake_gateway | intake_sessions |
| Orchestrator | prescription_orchestrator | rx_fill_sagas, saga_events |
| Refill Scheduler | refill_scheduler | refill_schedule_views, scheduler_events |
| Pharmacy Router | pharmacy_router | pharmacies, pharmacy_routes, pharmacy_submissions, pharmacy_callbacks |
| Physician Registry | physician_registry | physicians, physician_licenses, physician_routing |
| Payment | payment_service | customers, payment_methods, setup_intents, payment_intents, charges, refunds, stripe_subscriptions, disputes, stripe_webhook_events |
| Commerce | commerce_service | products, skus, billing_plans, coupons, coupon_redemptions, subscriptions, orders, order_items |
| Shipping | shipping_service | shipments, shipment_items, tracking_events |
| Notification | notification_service | contacts, campaigns, campaign_enrollments, messaging_preferences, notification_log |
| Webhook Receiver | webhook_receiver | webhook_events (normalized, fan-out state) |
Every service also has outbox and audit_events tables for event reliability and action audit, per the Canvas-first doctrine.
Multi-Tenancy
One Canvas instance serves every Organization — YourEra DTC and every GuideGLP client.
FHIR-native multi-tenancy via Patient.managingOrganization. Tenant isolation is enforced
in our service layer, not by Canvas. GuideGLP clients get perceived isolation —
white-labeled portals, tenant-scoped admin views, their own branded notifications — but
share infrastructure.
What Each GuideGLP Client Gets
- ✅ White-labeled patient portal at their domain (e.g.,
patients.sleepcorner.com). - ✅ Admin portal that shows only their patients (tenant-scoped at every service query).
- ✅ Email/SMS from their verified sender addresses with their branding.
- ✅ Intake forms at their domain with their colors, logo, and support copy.
- ✅ Their providers see tasks routed to them through Canvas
prescription_queue.
What We Do NOT Promise
- ❌ Data isolation inside Canvas — YourEra prescribers see every patient across every org.
- ❌ Dedicated infrastructure — one Canvas, one Postgres cluster, shared services.
Single-Org-For-Life
A patient belongs to exactly one organization for their entire lifetime with us.
A person who is both a DTC customer and a Biologics employee does not get modeled as two
records — they enter through one funnel. organization_id is set once at
patient_directory creation and is immutable through normal flows.
Superadmin corrective reassignment exists as a rare fix-it tool only.
Notification System
All outbound patient comms flow through the Notification Service. The service expanded significantly under Canvas-first: beyond the original delivery-only scope, it now owns a lightweight CRM layer (contacts + campaigns + preferences) that replaces HubSpot entirely.
Category Model (HIPAA-Aware)
Every notification falls into one of three categories. Opt-out rules are category-specific.
| Category | Examples | v1 Opt-Out Behavior |
|---|---|---|
| transactional | order confirmations, password reset, 2FA codes, payment receipts | Cannot opt out. Legally / service-essential. |
| clinical | provider messages, Rx status, refill reminders, appointment reminders, preflight warnings | Cannot opt out at v1. Future: per-template granularity via a companion override table. |
| marketing | drip campaigns, weekly tips, educational content, re-engagement | Freely opt-out per channel. One-click unsubscribe on every message. |
Campaign Types at v1
- Event-triggered drip — "on
intake.abandoned, send day-1, wait 3 days, send day-4, wait 7 days, unenroll." - Scheduled — cron-like, per-org timezone: "every Monday 9am send weekly_tip to active patients."
- One-off blast — admin composes, selects audience, sends.
Open/click tracking, A/B testing, inbound reply threading, and multi-language templates are explicitly deferred; schema is extensible.
Galleria Print Queue
Part of Shipping Service. When the orchestrator creates a Galleria (GMP) shipment, a print queue entry is auto-created; Galleria pharmacy staff see it in the Shipping Dashboard and print the label when packaging.
-- Print queue lifecycle
pending → printing → printed → (flows into pack/ship)
→ error → (retry)
| Endpoint | Method | Purpose |
|---|---|---|
/shipping/print-queue | GET | List prescriptions pending print |
/shipping/print-queue/:id/printed | POST | Mark a prescription as printed |
Secure Intake Validation
Prevents client-side price tampering. The Intake Gateway issues an HMAC-signed plan token at plan selection; the signature is verified when the Stripe PaymentIntent is created. Coupled with Stripe Elements (card data never touches our server) and per-org verified senders, PCI + PHI scope stays tight.
// 1. Server generates plan token when client selects a plan
const payload = { planId, amountCents, intakeType, organizationId, ts: Date.now() };
const data = base64url(JSON.stringify(payload));
const sig = HMAC-SHA256(data, PLAN_SIGNING_SECRET);
const planToken = data + '.' + sig;
// 2. Client sends planToken with Stripe PaymentIntent request
// 3. Server verifies on payment submission
const [data, sig] = planToken.split('.');
const expected = HMAC-SHA256(data, PLAN_SIGNING_SECRET);
if (sig !== expected) throw 'Tampered plan token';
if (payload.amountCents !== stripeAmount) throw 'Amount mismatch';
if (payload.organizationId !== resolvedOrg) throw 'Org mismatch';
if (Date.now() - payload.ts > 15 * 60 * 1000) throw 'Token expired';
Refactor Sequence
Under Canvas-first, services are built/retired in dependency order. Foundational services first, then the ones that depend on them. See canvas-first-principles §6 for the full rationale.
organizations rows with Canvas Organization mirrors.patient_directory + intake_snapshots. Migrate existing patient records; drop clinical columns. Add /patients/email-lookup endpoint.intake_sessions persistent table + abandonment sweeper + lead event emission.prescription_queue, portal_invite, and other SDK plugins to respect managingOrganization for routing + branding. Use Canvas Plugin Assistant (CPA) for development.refill.due; writes no Canvas resources.pharmacy_routes with GMP state-license matrix. Live routing re-evaluation per saga.contacts, campaigns, campaign_enrollments, messaging_preferences. Per-org verified senders (SES + Twilio). HIPAA-aware category model.guide-glp standalone service + DB. Migrate each GuideGLP client's patients into Canvas as Patient.managingOrganization = {their_canvas_org_id}. Their orders/subs now flow through shared services.What Retires
Under Canvas-first, substantial prior-architecture artifacts go away entirely.
| Retired Artifact | Reason | Replacement |
|---|---|---|
canvas-pioneer-integration-service | Responsibilities redistributed. | canvas-client library + webhook-receiver + specific sidecars. |
hubspot-sync-service (never built) | HubSpot retired from architecture. | notification-service (expanded: contacts + campaigns). |
guide-glp (standalone service + DB) | GuideGLP patients become Canvas-managed. | Shared services with Organization.kind='b2b'. |
iOS app + yourera-ios-app repo | Already deprecated. | Web-only. |
v0 patient-service monolith (~30 tables) | Clinical data moved to Canvas. | Slim patient-service (2 tables). |
| Boothwyn pharmacy integration | Dropped as a partner. | GMP primary, Strive fallback. |
patientRecords / intakeData JSONB tables | See redistribution map. | Canvas Patient + patient-service.intake_snapshots. |
| HubSpot webhooks + CRM sync | Not used, never built pipeline. | notification-service events. |
| Daily refill cron (Lambda/EventBridge) | Coarse cadence, gaps. | Event-driven refill-scheduler + per-minute worker + nightly reconcile. |
Testing Strategy
Each service is tested at four tiers:
- Unit tests — pure logic: state machines, preflight rules, preference checks, HMAC, template rendering, idempotency-key generation.
- Integration tests — with mocked HTTP dependencies (stub Stripe, Canvas, FedEx, pharmacy APIs). Assert state transitions + event emissions.
- Contract tests — every emitted/consumed event has a versioned schema; every inter-service API has a typed Zod contract. Cross-service consumers import from
@yourera/contracts. - E2E tests — browser-driven from intake through delivery for the happy path; targeted scenarios for blocked saga, payment failure, pharmacy rejection, custom-domain routing, tenant isolation.