Ehr-adapter 3.2.0: typed references, operation groups, and composable configs

Intro

A few weeks ago I posted ehr-adapter here (a Clojure library for defining EHR integrations as data). Since then the library has grown quite a bit (went from 2.0.1 to 3.2.0), and I wanted to share the three biggest pieces I added, and more importantly, the real problem each one solves.

If you haven’t read the original post, you can check it out here before continuing: Ehr-adapter: Declarative EHR integrations in Clojure

Thanks

Before I get into it, I want to genuinely thank you all. Honestly, I was a bit frustrated, unsure whether to keep going with ehr-adapter, but when I saw the downloads on Clojars climbing, I got excited and decided to keep pushing to where it is now. Thanks for the encouragement and support. Now, let’s get into what’s new.

Typed References

The first real headache came when some data got crossed incorrectly in the middle of a fairly heavy operation against a medical data provider’s server. The server didn’t warn me about anything. It failed silently, and in my rush I didn’t notice what was happening: incorrect data was reaching the point where a reference got resolved.

Up through 2.0.1, a reference like :ref/patient-id accepted any value present in the context: a string, a number, whatever. That’s flexible, but it can cause problems when you don’t have very meticulous control over every piece of data, and the interaction with the provider fails — just after the request already went out. It’s better for it to fail earlier. So I thought: I need to make references validate themselves too.

The solution was extending the reference syntax with an optional type: :ref#type/name.

:ref/patient-id           ;; no restrictions
:ref?/status               ;; optional, untyped
:ref#pos-int/age           ;; must be a positive integer
:ref?#uuid/request-id      ;; optional, but if present, must be a valid UUID

Validation runs through an extensible multimethod (ehr-adapter.reference.type/validate), so I didn’t limit myself to a closed list of types, anyone can add their own:

(defmethod ref-type/validate :icd-10-code
  [ref-data]
  (ref-type/check ref-data #(re-matches #"^[A-Z]\d{2}(\.\d{1,2})?$" %)))

;; Usage:
{:query {:diagnosis :ref?#icd-10-code/primary-diagnosis}}

All of this runs through a three-stage pipeline: check (coherence: you can’t declare something as required in one place and optional in another), partial-resolve (resolve what’s already available, leave the rest intact), and resolve (fails fast if something required is missing, permissive with optionals). The core idea: type errors get caught before the request goes out, not when the EHR hands you back a 400 in production.

Operation Groups

Everything was going great until I had to build a configuration to connect to AdvancedMD (another EHR provider, similar to eClinicalWorks). And I’m not bringing them up because they were a problem, but because that’s when I realized I had a serious efficiency problem in my own code. Picture this: you have several operations where most of the endpoint path is identical, which meant repeating that path in every single operation entry, a very common case being CRUD operations against the same endpoint. So I made two decisions: build a mechanism to group operations that share a repeated path, and make the :path field optional for operations within the same group that share a common path. Let’s take a look.

Before, if you had five operations on the same resource, you repeated the same path prefix five times:

:operations [{:name :search-patients :method :get :path ["v1" "Patient"] ...}
             {:name :read-patient    :method :get :path ["v1" "Patient" :ref/patient-id]}
             {:name :create-patient  :method :post :path ["v1" "Patient"] ...}
             ;; ... and so on
             ]

With OperationGroups, the prefix gets declared once:

:operations [{:prefix ["v1" "Patient"]
              :operations [{:name :search-patients :method :get ...}
                           {:name :read-patient :method :get :path [:ref/patient-id]}
                           {:name :create-patient :method :post ...}]}]

Groups can nest arbitrarily deep, and even the prefix can hold dynamic references (:ref/tenant-id) for multi-tenant scenarios. Everything gets “flattened” at compile time; zero extra runtime cost, it’s purely configuration organization.

Exporting and Importing Configurations

I got tired of copying configurations across the different environments I was working in. I even thought it’d be convenient to store these configurations in a Postgres JSONB column, to build an engine that would orchestrate connections to different EHRs without hardcoded configs. I wanted it to be as composable as possible.

But there was an obstacle.

ehr-adapter configurations mix data (URLs, names) with functions (:request-handler, middlewares, auth handlers), and functions can’t be serialized (or at least not easily, without polluting the final result). I added ehr-adapter.io.core to solve this: on export, every function gets replaced with a positional reference indicating exactly where it was:

;; Before exporting:
{:network {:request-handler babashka.http-client/request}}

;; After exporting:
{:network {:request-handler :ref#fn/network.request-handler}}

The full workflow looks like this: export a template (no secrets, safe to version), keep secrets separate (environment variables, a secret manager), then import and resolve everything together at runtime:

(def template (io/import {:format :edn :file-name "template.edn"}))
(def adapter (ehr/initialize secrets template))

That’s the case for a config stored in a local file. In my specific case, I exported my configuration to Transit JSON (yes, the library supports that format natively), and uploaded it to a Postgres database (the one I was already using). What I kept was just the map holding what’s needed to resolve each positional reference, which turns out to be pretty self-descriptive. And then to import it, since it’s not sitting locally, there’s no need to use import at all. I just get the Transit JSON as a string and do this:

(require '[ehr-adapter.io.transit :as transit]
         '[ehr-adapter.core :as ehr])

(def adapter-instance
  (ehr/initialize secrets (transit/<-string "transit json string")))

Current Status

ehr-adapter is now at 3.2.0, with 112 downloads on Clojars and a full test suite. It’s still a 100% data-driven library, and still subject to change (though I expect those changes to be fairly minor going forward). If you want to dig into the mechanics or give the library a try, here are the important links:

As always, I’m open to feedback, unusual use cases, or that one EHR that’s giving you headaches. Thanks for reading all the way through. If something sparked an idea while you were reading, drop a comment.

I love Clojure, and I love its community. Thanks again for your attention.