20 KiB
Medication Correlation Design Decisions
The problem this solves
A patient with a blood pressure of 140/90 receives metoprolol (a beta-blocker that
lowers blood pressure and heart rate). Forty-five minutes later, their systolic BP reads
95 mmHg. Without medication context, the system fires a WARNING_SYSTOLIC_BP alert, a
NEWS2 score increase, and potentially a RAPID_DETERIORATION trend alert — all because
the BP dropped. But the BP dropped because the medication is working as intended.
Clinicians who see these false positives repeatedly stop trusting the alert system. At that point, the system is worse than useless — it trains people to ignore alerts, including the real ones.
Phase 15 solves this by annotating alerts with medication context. The alert still fires (the BP is genuinely low and may need monitoring), but the details say:
SYSTOLIC_BP value 95 is below warning low of 90. — note: metoprolol 25mg (PO) administered 45 min ago
That single line changes the clinical interpretation from "something is wrong" to "the medication is working — keep monitoring."
How the pieces fit together
There are six components in Phase 15. Here is how a request flows through them, starting from when a nurse records a medication and ending when an annotated alert is created.
Nurse records medication
│
▼
┌─────────────────────┐
│ MedicationsController│ ◄── thin HTTP layer, no business logic
│ POST /encounters/ │
│ {id}/medications │
└────────┬────────────┘
│
▼
┌─────────────────────┐
│ FluentValidation │ ◄── rejects bad input before it reaches the service
│ (auto-registered) │ (empty drug name, zero dose, future timestamps)
└────────┬────────────┘
│
▼
┌─────────────────────┐
│ MedicationService │ ◄── checks encounter exists and is active,
│ │ persists to PostgreSQL
└─────────────────────┘
... time passes, vital signs arrive ...
New observation arrives (e.g. SYSTOLIC_BP = 95)
│
▼
┌─────────────────────┐
│ WarningEvaluator │ ◄── or News2Detector for composite scores
│ │
│ 1. Load threshold │ (from Redis cache)
│ 2. Check breach │ (is 95 < warningLow of 90?)
│ 3. Build details │ ("SYSTOLIC_BP value 95 is below warning low of 90.")
│ 4. ► Annotate ◄ │ NEW in Phase 15
│ 5. INSERT alert │ (idempotent — skips if one already open)
└────────┬────────────┘
│
step 4 calls
│
▼
┌────────────────────────────┐
│ MedicationCorrelationHelper│
│ │
│ "Was a relevant drug │
│ given to this patient │
│ within the last 90 │
│ minutes?" │
│ │
│ Uses MedicationService │
│ .GetRecentForEncounterAsync│
│ to query PostgreSQL │
│ │
│ Uses DrugVitalMappings │
│ from config to decide │
│ which drugs are "relevant"│
│ to which vital signs │
└────────────────────────────┘
Component-by-component: what it does and why it was built that way
1. MedicationAdministration entity
What it is: A database table (medication_administrations) that records when a drug
was given to a patient. Each row captures the drug name, dose, unit, route (PO = oral,
IV = intravenous, etc.), timestamp, and who gave it.
Why it's a separate table (not a column on observations):
Medications and vital sign observations are fundamentally different things. An
observation is a measurement — the patient's heart rate is 105 bpm right now. A
medication administration is an action — a nurse gave metoprolol 25mg at 10:15 AM.
They have different fields (dose, route, administered_by vs. value, unit, source),
different lifecycles, and different access patterns. Mixing them into one table would
require nullable columns everywhere and make queries harder to reason about.
Why decimal(10,4) for dose:
Medication doses can be fractional — 0.5mg of atropine, 2.5mg of metoprolol. Using an
integer would lose precision. Using float/double introduces floating-point rounding
errors (0.1 + 0.2 = 0.30000000000000004), which is unacceptable for medical dosing.
decimal stores exact values. The (10,4) precision allows doses up to 999999.9999,
which covers everything from micrograms to grams.
Why two composite indexes:
(encounter_id, administered_at)— the correlation query asks "what drugs were given to this encounter in the last 90 minutes?" This index makes that query fast.(encounter_id, drug_name)— supports the list endpoint when filtering by drug.
Without these indexes, every correlation check would do a full table scan on the medication_administrations table. On a busy hospital with thousands of medication records per day, that would add visible latency to every alert evaluation.
Why DeleteBehavior.Restrict on the Encounter FK:
If someone accidentally tries to delete an encounter that has medication records, the
database will reject the delete rather than silently cascade-deleting the medication
history. Medical records should never be silently deleted.
2. MedicationCorrelationOptions (configuration)
What it is: A C# class that maps drug names to the vital signs they affect. For
example, metoprolol maps to ["SYSTOLIC_BP", "HEART_RATE", "DIASTOLIC_BP"] because
beta-blockers lower blood pressure and heart rate. It also holds the correlation window
(default 90 minutes).
Why this is config, not a database table:
Drug-vital mappings change rarely — new drugs don't appear every week, and the
physiological effects of existing drugs don't change. Putting them in appsettings.json
means:
- They're version-controlled with the code (you can see in git who changed them and why)
- They're loaded once at startup, not queried from the database on every alert
- They can be overridden per environment (test vs. production) using the standard ASP.NET Core configuration hierarchy
- No migration needed when a mapping changes — just update the config and restart
If this were a database table, you'd need an admin UI, migration scripts, and a cache layer to avoid querying the table on every observation. The config approach avoids all of that complexity for data that changes maybe once a quarter.
Why 90 minutes as the default window: Most oral medications reach peak effect within 60-90 minutes. IV medications act faster (minutes), but their effects also persist. A 90-minute window catches most therapeutic scenarios. If it were too short (30 minutes), oral medications like metoprolol (peak effect at 60 minutes) would be missed. If it were too long (6 hours), unrelated medication effects would pollute the annotation. The value is configurable per environment if a clinical team wants to adjust it.
Why drug names are lowercase keys: Drug names in clinical systems come in all forms — "Metoprolol", "METOPROLOL", "metoprolol". By normalizing keys to lowercase in the config and doing case-insensitive lookups, the correlation works regardless of how the nurse typed the drug name. This avoids a class of bugs where correlation silently fails because the case doesn't match.
3. MedicationService
What it is: The business logic layer for medication administration CRUD. It handles
creating records, listing them with pagination, looking up by ID, and the critical
GetRecentForEncounterAsync method used by the correlation helper.
Why the service throws exceptions instead of returning error codes:
The project uses a pattern where services throw typed domain exceptions
(NotFoundException, ConflictException) and a global ExceptionHandlerMiddleware
converts them into HTTP responses (404, 409). This pattern means:
- The service doesn't know or care about HTTP — it could be called from a Kafka consumer, a background job, or a test, and the error handling still works
- The controller stays thin (no
if (result.IsError) return NotFound(...)chains) - Error handling is consistent across every endpoint — one middleware, one set of rules
Why GetRecentForEncounterAsync reverses the mapping:
The config maps drugs → vital signs (metoprolol → SYSTOLIC_BP). But the correlation
query starts from the other direction: "I have a SYSTOLIC_BP alert — which drugs affect
SYSTOLIC_BP?" The method inverts the mapping at query time: it scans all entries in
DrugVitalMappings to find keys whose values contain the target observation code, then
queries the database for those specific drug names.
You might ask: "Why not store the mapping both ways?" Because maintaining two maps that must stay in sync is a bug waiting to happen. The drug → vitals direction is the natural way clinicians think about it ("metoprolol affects blood pressure"), so that's how it's stored. The reverse lookup happens in code, where it's guaranteed to be consistent.
Why the DB query uses relevantDrugs.Contains(m.DrugName.ToLower()):
This translates to a SQL WHERE drug_name IN (...) clause, which PostgreSQL handles
efficiently with the composite index. The alternative — loading all medications and
filtering in memory — would transfer unnecessary data from the database. Let the database
do what it's good at.
4. MedicationsController
What it is: Three HTTP endpoints:
POST /api/v1/encounters/{encounterId}/medications— record a new administrationGET /api/v1/encounters/{encounterId}/medications— list with pagination and optionalsincefilterGET /api/v1/medications/{id}— look up a single record
Why the controller is almost empty:
The controller's only job is to translate HTTP concepts (route parameters, query strings,
status codes) into service calls and back. All business logic lives in
MedicationService. This is the same pattern used by OrdersController and every other
controller in the project. The benefit: you can test business logic by calling the
service directly (no need to spin up an HTTP server), and the controller is so simple
that bugs in it are immediately obvious.
Why POST returns 201, not 200:
HTTP 201 means "a new resource was created." This follows REST conventions and tells the
client that the medication record now exists and can be fetched at the returned URL. It's
a small detail, but API consumers (other teams, integration partners) expect it.
5. FluentValidation
What it is: A validator that rejects invalid requests before they reach the service layer. It checks: drug name is not empty, dose is greater than zero, future timestamps are rejected (a medication can't be recorded as given tomorrow), and string fields don't exceed database column limits.
Why validation is separate from the service: Validation and business rules are different things. Validation asks "is this request well-formed?" (non-empty drug name, positive dose). Business rules ask "is this operation allowed?" (encounter must be active). Keeping them separate means:
- Validation runs automatically via FluentValidation middleware — no manual calls needed
- Validation errors return 400 (bad request), business rule violations return 404/409
- The service can trust that inputs are well-formed and focus on domain logic
- Validators are auto-registered by scanning the assembly — adding a new validator just means creating a class
Why AdministeredAt allows up to 5 minutes in the future:
Clock skew. The nurse's tablet might be a few minutes ahead of the server. Rejecting a
timestamp that's 30 seconds in the future because of clock drift would be frustrating.
Five minutes is generous enough to handle clock differences without allowing genuinely
wrong timestamps (like recording a medication as given tomorrow).
6. MedicationCorrelationHelper
What it is: The bridge between the medication subsystem and the alerting subsystem. It
has one public method: TryAnnotateDetailsAsync. Given an encounter ID, an observation
code, and the existing alert details string, it checks if a relevant medication was given
within the correlation window. If yes, it appends a note. If no, it returns the original
string unchanged.
Why it's a separate class (not inline in WarningEvaluator):
Two different alert paths need medication annotation: WarningEvaluator (for threshold
warnings) and News2Detector (for composite NEWS2 scores). If the annotation logic lived
inside WarningEvaluator, it would need to be duplicated in News2Detector. The helper
class means both callers share the same logic, and if the annotation format changes, it
changes in one place.
Why it annotates rather than suppresses: A patient's BP of 95 after metoprolol is expected but still clinically relevant. The BP is genuinely low. If a nurse doubled the dose by mistake, or if the patient is hypotensive beyond what the medication should cause, the alert needs to fire. Suppressing it entirely would be a patient safety risk. Annotation gives the clinician the context to make that judgment — it says "this might be the medication" without saying "ignore this."
Smart suppression (automatically silencing alerts that are clearly medication-related) is deferred to a future phase because it requires more sophisticated logic: was the drop proportional to the dose? Is the patient also septic? These judgment calls are hard to encode safely in software.
Why it only annotates the most recent matching medication: If a patient received metoprolol at 9:00 AM and again at 10:30 AM, and their BP drops at 11:00 AM, the 10:30 AM dose is the one the clinician cares about — it's the most likely cause. Listing every medication in the window would clutter the alert. The most recent match gives the most actionable context.
What is NOT annotated and why
| Alert path | Annotated? | Reason |
|---|---|---|
WarningEvaluator |
Yes | Threshold warnings are the primary source of medication-related false positives |
News2Detector |
Yes | NEWS2 is a composite of 7 vital signs — any of which could be medication-affected |
TrendDetector |
No | A rapid trajectory change after medication is still clinically important — the rate of change matters even if the medication explains it |
SirsDetector / QsofaDetector |
No | Sepsis alerts should never be downplayed by medication context — missing sepsis kills patients |
| Critical alerts (sync path) | No | Critical values (e.g., SBP below 70) are emergencies regardless of medication context — patient safety takes priority |
This is a deliberate clinical safety decision. The cost of a false negative (missing a real emergency because the system said "it's just the medication") is much higher than the cost of a false positive (an extra annotation on an expected alert).
How the annotation hooks into existing alert creation
Both WarningEvaluator and News2Detector already follow a pattern:
- Check if a threshold is breached
- Build a details string describing the breach
- INSERT the alert into the database
Phase 15 adds one step between 2 and 3:
2. Build details string
2.5 ► Call MedicationCorrelationHelper.TryAnnotateDetailsAsync ◄
3. INSERT the alert (with the possibly-annotated details)
This minimal insertion point means no changes to the threshold logic, the idempotent INSERT pattern, the outbox event publishing, or the alert suppression logic. Each of those systems continues to work exactly as before.
The annotated details string is also included in the outbox event payload, so downstream consumers (notifications, dashboards) receive the medication context without needing their own correlation logic.
News2Detector annotation strategy
NEWS2 is different from single-vital warnings because it's a composite score of 7 parameters (respiratory rate, SpO2, systolic BP, heart rate, consciousness, temperature, supplemental O2). A single NEWS2 alert doesn't correspond to one observation code — it's a rollup.
The annotation loops through all 7 parameter codes and checks if any has a correlated medication. It appends the first match found. This keeps the annotation concise (one drug per alert) while still catching the most relevant context. If a patient received both morphine (affects RESP_RATE) and metoprolol (affects SYSTOLIC_BP), the annotation shows whichever parameter code comes first in the loop — which is fine, because the purpose is to flag "there's medication context here" rather than to provide a complete pharmaceutical review.
Data flow summary
HTTP POST
│
▼
┌────────────────┐
│ Medications │
│ Controller │
└───────┬────────┘
│
▼
┌────────────────┐ ┌─────────────────┐
│ Medication │──────►│ PostgreSQL │
│ Service │ │ medication_ │
└────────────────┘ │ administrations │
└────────┬────────┘
│
... later, observation arrives ... │
│
┌────────────────┐ │
│ Warning │ │
│ Evaluator │ │
│ │ │
│ builds details│ │
│ │ │ │
│ ▼ │ │
│ Correlation │◄───────────────┘
│ Helper │ queries recent meds
│ │ │ for this encounter +
│ ▼ │ observation code
│ annotated │
│ details │
│ │ │
│ ▼ │
│ INSERT alert │──────► PostgreSQL clinical_alerts
│ + outbox event│──────► outbox_events → Kafka
└────────────────┘
Testing strategy
The tests are structured in three files, each targeting a different layer:
MedicationServiceTests (4 tests) — tests the service layer directly. Can a medication
be created on an active encounter? Does a discharged encounter get rejected? Does
pagination work? Does the time-window filter exclude old records? These tests call
IMedicationService methods directly, bypassing HTTP.
MedicationCorrelationTests (5 tests) — tests the full integration from medication
recording through alert creation. These seed a medication into the database, then invoke
WarningEvaluator.EvaluateAsync and check whether the resulting alert's details field
contains the medication annotation. This is the most important test file because it
verifies the end-to-end behavior that Phase 15 exists to provide.
MedicationValidationTests (3 tests) — tests the HTTP validation layer. These send
invalid requests via HttpClient and assert 400 responses. They don't seed encounters
because the validator rejects the request before the service layer runs.
All tests run against a real PostgreSQL database and real Redis instance (using test containers on different ports). No mocking. This means the tests catch real issues like SQL translation failures, index problems, and configuration registration mistakes that mocks would miss.