Aylo Rules Engine
A clinical decision support service that turns preventative-care guidelines into versioned, auditable rules-as-data. Built at Ensomata for Aylo's care teams.
- Role
- Lead Software Engineer
- Company
- Ensomata
- Timeline
- 2021 – 2024
- Stack
- TypeScript · NestJS · PostgreSQL · AWS
The problem
Primary care runs on preventative guidelines, and there are more of them than any human comfortably tracks. A man of 65 to 75 may be due a one-time abdominal aortic aneurysm ultrasound. A patient with thirty pack-years of smoking history qualifies for lung-cancer screening, but only between 55 and 77, and only if there's no recent scan on file. Shingles vaccine after 50; pneumonia vaccination on two different schedules; colorectal screening unless a colonoscopy already covered the interval. Every one of these turns on age, sex, diagnosis history, what was already ordered, and when.
Ensomata builds clinical workflow software for Aylo, a multi-clinic primary-care group. The rules engine is the piece that keeps those guidelines from living in anyone's head: at each visit it checks the patient's history against a catalog of screening, imaging, lab, and immunization rules and hands the care team the short list of what this patient is actually due for. Medical assistants see the recommendations while rooming the patient and can queue orders; physicians review and sign. I led engineering at Ensomata from 2021 to 2024, and my team and I built and ran this system: the evaluation service and the versioned store its rules live in.
The shape of it
The engine is a small, single-purpose NestJS service with one route that matters: POST a snapshot of a patient's chart (demographics, diagnosis list, order and result history, immunizations, smoking history) and get back the recommendations that patient is eligible for. Rules live in PostgreSQL, and evaluation happens in memory. The service holds no patient data at rest: the chart arrives with the request and leaves with the response, which keeps its compliance surface small. Requests are authenticated with Cognito-issued JWTs, and the whole thing ships as a Docker image from a pnpm/Turborepo monorepo, promoted through dev and prod by AWS CodePipeline.
Rules as data
The design decision the system stands on: a clinical rule is a row, not a release.
Each rule stores its eligibility logic as a JSON condition tree of boolean
all/any combinators over small, typed leaf conditions
called operands:
{
"operand": "all",
"value": [
{ "operand": "age", "operator": ">=", "value": 65 },
{ "operand": "age", "operator": "<=", "value": 75 },
{ "operand": "gender", "operator": "=", "value": "M" },
{ "operand": "diagnosis", "diagnosisGroupSourceId": "…" },
{ "operand": "previousTest", "duration": { "months": 12 }, "orderableLabItemId": "…" }
]
}
That's the abdominal aortic aneurysm screen, lightly simplified: male, 65 to 75,
carrying a qualifying diagnosis, no matching ultrasound in the last twelve months.
The engine parses trees like this into a composite of about eighteen operand types:
age, gender, race, diagnosis-group membership, test-recency windows, blood-pressure
thresholds, smoking pack-years, vaccine counts, even "is it flu season." Each is a
small class with a validate(patient) method and an exhaustive spec.
Storing rules this way moves authoring out of the deploy cycle: eligibility changes go through an authenticated CRUD API and take effect immediately, no release required. The seam for new capability is narrow and boring on purpose: one new operand class, one case in the parser, one spec file. And a rule can't be saved broken. Every operand validates its own operators and values at construction time, so a malformed rule is rejected when it's authored instead of failing during a patient visit.
Missing data gets the same care. Each operand declares what absence means clinically: no recorded pack-years reads as "not a smoker," so the lung-cancer screen stays quiet; an absent immunization record leans toward recommending the vaccine. The safe default depends on which way the recommendation cuts, and it's encoded per operand rather than globally.
Governance in the schema
Clinical rules change: guidelines update, vaccines get added, age bands shift. In
this domain, "who changed what, when" isn't an optional question. The rule table's
primary key is (id, version). Edits never mutate. Updating a rule
soft-deletes the current version and inserts the next one, stamped with the author's
identity. A history endpoint reconstructs the full changelog, diffing consecutive
versions field by field. Each rule also carries its patient-facing description in
English and Spanish, so the recommendation a clinician sees and the explanation a
patient gets travel together.
The system's earlier life shows the same instinct in a different medium. Before the
tree engine, rules were TypeScript classes, and every clinical policy change shipped
as a database migration named for the change itself: EnableFluPneumonia,
Shingrix, Pneumonia19To64. The migration history reads as a
clinical changelog going back to the first rule.
Replacing the engine mid-flight
The JSON-tree engine is generation two. Generation one was rules-as-code: one TypeScript class per screening, run through an off-the-shelf rules library. It was serving production traffic the whole time its replacement was being built. So rather than a cutover weekend, the service ran both engines on every request and logged every place the two disagreed, and the new engine earned trust against real requests before its answers were the ones returned. The cutover, when it finally happened, was a one-line change.
Testing where the risk lives
The correctness burden sits in the operands, so that's where the tests sit: each operand has a truth-table spec covering every operator against boundary values and missing data. The shared predicate library that reads order history gets tested just as hard, since it has to ignore cancelled, unreviewed, and deleted orders while telling immunizations from labs. Alongside the automated suite, each clinical rule has a human-readable JSONC test case pairing the rule definition with sample patients, reviewable by people who think in guidelines rather than TypeScript.
What carried forward
A few habits from this system shaped how I build now. Rules engines want to become data: the sooner eligibility logic is rows instead of releases, the sooner the people who own the policy can own the change. Governance belongs in the schema, since append-only versions and author attribution cost almost nothing up front and are nearly impossible to retrofit. And an engine that makes clinical decisions never gets replaced big-bang. You run old and new side by side, log the disagreements, and switch when the log goes quiet.