DK Back to case study

Rx Price Check — System Design

Version 0.1 (draft for review) · Scope Mobile web + native in-app tool · Owner TBD


1. What we're building

A member-facing tool that answers two questions in under a minute:

  1. What will this prescription actually cost me?
  2. Is there something cheaper my doctor could prescribe instead?

The member types a drug name, gets an AI-assembled set of lower-cost and higher-cost alternatives with generic/brand clearly marked, picks a strength and form, then sees real prices at pharmacies near them in both a list and a map.

Goals

Non-goals (v1)

Success metrics

Metric Target
Search → pharmacy list completion rate ≥ 55%
Median time to first price ≤ 3.5 s
Alternatives panel engagement (tap-through) ≥ 30%
"Was this helpful?" positive rate on AI panel ≥ 70%
Clinically inappropriate suggestion rate (audited sample) < 0.5%

2. User journey

  Search            Alternatives          Configure           Location
  ┌───────┐         ┌───────┐             ┌───────┐           ┌───────┐
  │ "lipi"│  ───▶   │ brand │    ───▶     │ 20 mg │   ───▶    │ detect│
  │  ───  │         │ ───── │             │ tablet│           │  or   │
  │ typea-│         │generic│             │  30ct │           │ type  │
  │ head  │         │ alts  │             │       │           │address│
  └───────┘         └───────┘             └───────┘           └───────┘
                         │                                        │
                         │ higher-cost                            ▼
                         │ options collapsed              ┌───────────────┐
                         ▼                                │ Pharmacy list │
                   "Ask your doctor"                      │   ⇄  Map      │
                    disclaimer shown                      │  pin → price  │
                                                          │    + distance │
                                                          └───────────────┘

Two entry points beyond search: deep link from a claims/EOB row ("price this refill"), and a saved-medication list on the member's home screen.


3. Architecture

┌──────────────────────────────────────────────────────────────┐
│  Mobile client (React Native + shared RN-Web)                │
│  search · alternatives · configurator · map · list           │
└───────────────┬──────────────────────────────────────────────┘
                │ HTTPS / OAuth2 member token
┌───────────────▼──────────────────────────────────────────────┐
│  BFF (GraphQL or REST-for-mobile)                            │
│  session, response shaping, field-level PHI redaction        │
└──┬──────┬──────────┬──────────────┬─────────────┬────────────┘
   │      │          │              │             │
┌──▼───┐ ┌▼────────┐ ┌▼───────────┐ ┌▼──────────┐ ┌▼──────────┐
│Drug  │ │Alterna- │ │  Pricing   │ │ Pharmacy  │ │  Member   │
│Search│ │tives    │ │  Service   │ │ Network   │ │ Context   │
│Svc   │ │Svc (AI) │ │            │ │ Service   │ │  Service  │
└──┬───┘ └──┬──────┘ └─┬──────────┘ └──┬────────┘ └──┬────────┘
   │        │          │               │             │
┌──▼────────▼──┐  ┌────▼──────────┐ ┌──▼─────────┐ ┌─▼─────────┐
│ Drug KB      │  │ RTPB / PBM    │ │ Network    │ │ Eligibility│
│ RxNorm, NDC  │  │ adjudication  │ │ roster +   │ │ accumulators│
│ FDA Orange   │  │ Cash/discount │ │ Google     │ │ plan design │
│ Book, class  │  │ price feeds   │ │ Places     │ │             │
│ + vector idx │  └───────────────┘ └────────────┘ └─────────────┘
└──────────────┘
        ▲
        │  retrieval only — candidate set is deterministic
┌───────┴────────────────────┐
│ LLM (BAA-covered endpoint) │  ranks + explains. Never invents drugs.
└────────────────────────────┘

Why a BFF

The mobile client must never hold plan design logic, and must never receive fields it doesn't render. The BFF composes one /search-results and one /pharmacy-prices response so the phone makes two calls, not nine, and strips anything the screen doesn't need before it leaves the trust boundary.


4. Services

4.1 Drug Search Service

Typeahead over a normalized drug index.

Latency budget: p95 ≤ 120 ms.

4.2 Alternatives Service — the AI layer

This is the part most likely to go wrong, so the design deliberately limits what the model is allowed to do.

Two-stage: deterministic retrieval, then model ranking.

Stage 1 — candidate generation (no LLM). Given the searched RXCUI and the member's plan:

Candidate type Rule
Generic equivalent Same RxNorm ingredient + strength + form, Orange Book AB-rated
Therapeutic alternative Same USP therapeutic class / ATC level 4, on formulary
Formulary-preferred Plan's step-therapy or preferred agent for that class
Different form/strength Same ingredient, cheaper package (e.g. two 20 mg vs one 40 mg — flag only, never advise splitting)
Higher-cost Same class, higher member cost — shown for context, collapsed by default

Everything in the candidate set is a real, dispensable, on-market product from our own KB. The model cannot add to this set.

Stage 2 — LLM ranking and explanation. The model receives the candidate list plus structured cost data and returns, per candidate: a rank, a one-sentence plain-language reason, and a requiresNewPrescription flag. The prompt is constrained to a JSON schema; responses that fail schema validation or that reference an RXCUI outside the candidate set are rejected and we fall back to a rules-only ordering (cost ascending, generic-equivalent first).

Guardrails, all enforced outside the model:

Evaluation: a golden set of ~500 (drug, plan) pairs reviewed by a pharmacist, scored on clinical appropriateness, cost-order correctness, and explanation accuracy. Run on every prompt or model change; block release below threshold. Plus a shadow-mode audit of 1% of live traffic reviewed weekly.

Caching: alternatives for a (RXCUI, formulary ID) pair are deterministic given the same KB snapshot. Cache in Redis for 24 h. Expected hit rate > 90%, which also keeps LLM spend near-flat as traffic grows.

4.3 Pricing Service

Three price types, clearly labeled in the UI:

  1. Plan price (real-time benefit). NCPDP Real-Time Prescription Benefit via Surescripts or the PBM's RTPB API. Returns member cost at a specific pharmacy given current deductible/accumulator state. This is the number we lead with.
  2. Estimated plan price. Fallback when RTPB is unavailable or times out: computed from formulary tier + benefit design + accumulators. Labeled estimate.
  3. Cash / discount price. Non-insurance price feeds. Shown when it beats the plan price, with a warning that cash purchases may not count toward the deductible.

RTPB calls are fanned out per pharmacy, capped at the 15 nearest in-network pharmacies, with a 1.5 s budget and partial rendering — the list paints with distances first and prices fill in.

Accumulator state changes; prices are cached at most 15 minutes and always stamped with "as of."

4.4 Pharmacy Network Service + Google Maps

Cost control: Autocomplete session tokens, a 300 ms debounce, radius search served from our own DB, Distance Matrix only for what's on screen, and a per-member daily quota. Map tiles are the largest line item — cache aggressively and avoid re-initializing the map on tab switches.

PHI boundary: member identifiers, drug names, and prices are never sent to Google. We send an address string or a lat/lng, nothing else. This is stated in the privacy copy.

4.5 Member Context Service

Eligibility, plan design, formulary ID, deductible and OOP-max accumulators, saved addresses, saved medications. Every read is audit-logged.


5. API surface

All endpoints require a member-scoped OAuth2 token. Accept-Language honored.

GET /v1/drugs/suggest?q=lipi&limit=8

{
  "suggestions": [
    { "rxcui": "153165", "display": "Lipitor", "genericName": "atorvastatin calcium",
      "type": "BRAND", "hasGeneric": true },
    { "rxcui": "83367", "display": "Atorvastatin calcium",
      "type": "GENERIC", "brandNames": ["Lipitor"] }
  ]
}

GET /v1/drugs/{rxcui}/alternatives

{
  "requested": {
    "rxcui": "153165", "name": "Lipitor", "type": "BRAND",
    "formularyTier": 3, "estimatedMonthlyCost": { "amount": 84.00, "currency": "USD" }
  },
  "lowerCost": [
    { "rxcui": "83367", "name": "Atorvastatin calcium", "type": "GENERIC",
      "relationship": "GENERIC_EQUIVALENT", "orangeBookCode": "AB",
      "formularyTier": 1, "estimatedMonthlyCost": { "amount": 9.00 },
      "estimatedMonthlySavings": { "amount": 75.00 },
      "requiresNewPrescription": false,
      "reason": "Same active ingredient as Lipitor and rated equivalent by the FDA.",
      "reasonSource": "LLM_RANKED" }
  ],
  "higherCost": [ /* same shape */ ],
  "disclaimer": "Costs are estimates for your plan. Talk to your prescriber before changing any medication.",
  "asOf": "2026-09-13T14:22:05Z"
}

reasonSource is LLM_RANKED or RULES_FALLBACK — logged and used to monitor fallback rate.

GET /v1/drugs/{rxcui}/configurations

Returns available strengths, dose forms, and package quantities for the concept, so the two dropdowns are populated from real dispensable NDCs rather than a static list. Selecting a strength filters the form options and vice versa — the client must handle empty intersections (e.g. 80 mg has no oral suspension).

POST /v1/prices/pharmacies

{
  "ndc": "00093505698",
  "quantity": 30, "daysSupply": 30,
  "location": { "lat": 33.1507, "lng": -96.8236 },
  "radiusMiles": 10,
  "sort": "PRICE_ASC"
}

Response: pharmacy array with ncpdpId, name, address, lat, lng, distanceMiles, driveTimeMinutes, networkStatus (PREFERRED | IN_NETWORK | OUT_OF_NETWORK), price ({ amount, type: PLAN_RTPB | PLAN_ESTIMATE | CASH, appliesToDeductible }), hours, phone, googlePlaceId.

Streams via chunked response or returns priceStatus: PENDING per row with a follow-up poll, so the list is never blocked on the slowest RTPB call.


6. Data model (core entities)

DrugConcept       rxcui, name, type(BRAND|GENERIC), ingredientId, classes[]
DispensableProduct ndc, rxcui, strength, doseForm, packageQty, labeler, obCode
FormularyEntry    formularyId, rxcui, tier, requiresPA, stepTherapy, qtyLimit
Pharmacy          ncpdpId, name, address, geom(Point), googlePlaceId, chainId
NetworkStatus     contractId, ncpdpId, status, effectiveDates
PriceQuote        ndc, ncpdpId, memberId(hashed), amount, type, asOf, ttl
MemberContext     memberId, planId, formularyId, accumulators, savedAddresses[]

PriceQuote.memberId is stored hashed with a per-environment pepper; quotes expire on a TTL index.


7. Non-functional requirements

Concern Target
Typeahead p95 120 ms
Alternatives p95 (cache hit) 250 ms
Alternatives p95 (cache miss, LLM) 2.5 s, with skeleton UI from 0 ms
Pharmacy list first paint 1.2 s (distances), prices fill within 3 s
Availability 99.9%
Map interaction 60 fps pan/zoom, ≤ 40 pins rendered, cluster beyond that

Degradation ladder. LLM down → rules-based ordering, no reason text. RTPB down → estimated prices, labeled. Google Maps down → list view only, map tab disabled with an explanation. Network roster stale → show prices with an "as of" date rather than an empty state.


8. Privacy, security, compliance


9. Clinical and legal guardrails

  1. Every alternatives view carries the prescriber disclaimer, non-dismissible.
  2. Prices are labeled estimate unless they came from a real-time benefit check.
  3. Cash-price rows warn that the spend may not apply to the deductible.
  4. No dosing, splitting, tapering, or substitution instructions, ever.
  5. Narrow-therapeutic-index, biologic, and controlled substances: generic-equivalent alternatives only, no class switching.
  6. Pharmacist sign-off required on the class suppression list and on the explanation prompt, re-reviewed quarterly.
  7. Copy is reviewed by legal and by the plan's clinical team before launch and on any change to AI-generated text.

10. Error and empty states

Situation Behavior
No search results "No match for X. Check the spelling, or search the generic name." Plus 3 nearest lexical matches.
Drug not on formulary Show it, marked not covered, with cash price and covered alternatives in the same class.
Drug requires prior authorization Tier badge plus "Needs approval" chip and a link to the PA explainer.
No alternatives found Say so plainly and go straight to the configurator. Don't invent options.
No in-network pharmacy within radius Widen to 25 mi automatically, state that we did, and surface mail order.
Location permission denied Address field, focused, with saved addresses listed.
RTPB timeout Estimated price with an "estimate" chip and a retry affordance.

11. Accessibility


12. Analytics

Instrument: search submitted, zero-result searches (drives the synonym dictionary), alternative card tapped, alternative type tapped (generic-equivalent vs. class switch), configurator changes, location method chosen, list/map toggle, pin tapped, pharmacy detail opened, "call pharmacy" tapped, AI panel helpfulness vote, fallback-to-rules rate, RTPB timeout rate.

Track estimated savings surfaced vs. a proxy for savings realized (subsequent claims for the suggested alternative) — that's the number that justifies the tool.


13. Rollout

Phase Contents
0 Drug search + configurator + list-only pharmacy prices, estimated pricing. Internal employees.
1 Alternatives service in shadow mode — generate, log, pharmacist-audit, don't display.
2 Alternatives shown to 10%, holdout for measurement. RTPB live.
3 Map view, Distance Matrix, pin price labels.
4 Full rollout, deep links from claims, saved medications, refill reminders.

14. Open questions