A pattern for wrapping external APIs in idiomatic Clojure, developed for clj-gridx and applicable to any API client library (e.g., clj-oa3, clj-caiso).
Two layers over the raw HTTP response, connected by coercion functions that preserve the original data via metadata.
HTTP JSON ──► Raw EDN (camelCase, strings) ──► Coerced Entities (namespaced, typed)
▲
│ metadata: {:ns/raw <original>}
Preserve the API response as-is. The JSON-to-EDN conversion (via Cheshire, data.json, Hato, etc.) gives you maps with camelCase string keys/values.
;; What the API returns (after JSON parse)
{:startIntervalTimeStamp "2026-03-08T00:00:00-0800"
:intervalPrice "0.032176"
:priceStatus "Final"
:priceComponents [{:component "cld" :intervalPrice "0.000351" ...} ...]}
Define schemas that mirror the JSON shape exactly. Use these for boundary validation — confirming the API returned what you expected.
(def RawPriceDetail
[:map
[:startIntervalTimeStamp :string]
[:intervalPrice :string]
[:priceStatus :string]
[:priceComponents [:vector RawPriceComponent]]])
(defn raw-curves [response] (get-in response [:body :data]))
(defn validate-raw [body] (m/explain RawPricingResponse body))
(defn success? [response] ...)
Transform raw data into idiomatic Clojure values:
| Raw type | Coerced type | Example |
|---|---|---|
| String timestamp | java.time.Instant (UTC) | "2026-03-08T00:00:00-0800" → 2026-03-08T08:00:00Z |
| Time period | tick interval (:tick/beginning, :tick/end) | start + duration → {:tick/beginning instant :tick/end instant} |
| String decimal | BigDecimal | "0.032176" → 0.032176M |
| String enum | Namespaced keyword | "Final" → :gridx.status/final |
| camelCase key | Namespaced keyword | :intervalPrice → :gridx.interval/price |
Use a consistent namespace hierarchy:
:gridx.interval/timestamp ; entity.field
:gridx.component/mec ; entity.enum-value
:gridx.status/final ; domain.enum-value
:gridx.price-type/generation ; domain.enum-value
(def Interval
[:map
[:tick/beginning inst?]
[:tick/end inst?]
[:gridx.interval/price decimal?]
[:gridx.interval/status [:enum :gridx.status/final :gridx.status/preliminary]]
[:gridx.interval/components [:vector Component]]])
Add derived values as regular keys during coercion when they provide genuinely new information. Prefer this over protocols or lazy computation for cheap, always-useful derivations.
Caveat: Don't add a "computed" value that duplicates something already in the raw data. If the API already provides a total, don't re-sum the components — that's a validation concern, not a data field.
;; Good: genuinely derived value not in the raw data
(defn ->daily-summary [intervals]
{:daily/peak-price (apply max (map :interval/price intervals))
:daily/min-price (apply min (map :interval/price intervals))})
;; Bad: re-computing something the API already provides
;; :interval/component-total when :interval/price already exists
Every coerced entity attaches the original raw data as metadata:
(-> coerced-map
(with-meta {:gridx/raw raw}))
This enables:
;; Access raw data from any coerced entity
(-> interval meta :gridx/raw :startIntervalTimeStamp)
;=> "2026-03-08T00:00:00-0800"
Choose the type based on what the timestamp means, not just what the API sends:
java.time.Instant (UTC) — for point-in-time eventsUse for timestamps that represent "when something happened/applies globally": interval timestamps, trade times, meter readings.
API string "-0800" → OffsetDateTime (parse) → Instant (store)
No offsets, no zones, no ambiguity. Consumer provides ZoneId for display: (.atZone instant zone-id).
java.time.OffsetDateTime — for calendar boundariesUse for timestamps that represent "a date/time in the market's local context": curve start/end times, trading day boundaries, delivery periods.
API string "-0800" → OffsetDateTime (store as-is)
Preserves the offset from the API, which conveys market-local meaning (e.g., midnight Pacific). An offset like -08:00 cannot be converted to a ZonedDateTime without assuming a zone — multiple zones share the same offset. Don't assume; let the consumer assert the zone if they know it:
(.atZoneSameInstant offset-dt (ZoneId/of "America/Los_Angeles"))
Use for data that represents a span of time (price intervals, delivery periods, alert windows). Any map containing :tick/beginning and :tick/end is a tick interval, enabling Allen's interval algebra: t/relation, t/contains?, t/concur, t/meets?, etc.
Assoc tick keys directly on the entity map — don't nest them in a sub-map. This makes the entity itself a tick interval, usable without unwrapping:
;; In the coercion function, after building the base map:
(cond-> base-map
(and date-start time-start)
(assoc :tick/beginning (LocalDateTime/of date-start time-start))
(and date-end time-end)
(assoc :tick/end (LocalDateTime/of date-end time-end)))
The result is an entity map that doubles as a tick interval:
{:midas.value/name "summer mid peak"
:midas.value/price 0.1671M
:midas.value/unit :midas.unit/dollar-per-kwh
:tick/beginning #time/date-time "2023-06-02T06:00"
:tick/end #time/date-time "2023-06-02T06:59:59"
...}
;; Interval algebra works directly on the entity
(require '[tick.core :as t])
(t/contains? price-interval some-instant) ;=> true/false
(t/relation price-interval other-interval) ;=> :meets, :overlaps, etc.
Rules for tick interval keys:
LocalDateTime when the API provides date + time without timezone (let consumers attach timezone if needed)Instant when the API provides absolute timestamps with offset/timezone:tick/beginning / :tick/end when the source fields are non-nil (use cond->){:optional true} in the Malli schematick/tick as a dependency (currently {:mvn/version "1.0"}):ns/raw metadata-0800 vs -08:00); use a custom DateTimeFormatter as neededFor operations on known data shapes, use regular functions:
(defn peak-price [curve]
(apply max (map :gridx.interval/price (:gridx.curve/intervals curve))))
:extend-via-metadata protocols (when polymorphism is needed)Use when multiple data sources must support the same operations with different implementations:
(defprotocol PriceSource
:extend-via-metadata true
(price-at [this timestamp])
(available-range [this]))
Each source's coercion attaches its own implementation:
(defn ->gridx-curve [raw]
(-> {:source/type :gridx ...}
(with-meta {`price-at (fn [this ts] ...)
`available-range (fn [this] ...)})))
(defn ->caiso-curve [raw]
(-> {:source/type :caiso ...}
(with-meta {`price-at (fn [this ts] ...) ; different impl
`available-range (fn [this] ...)})))
Don't reach for this prematurely. Start with plain functions; refactor to protocols when you have two concrete sources that need polymorphism.
Consider clojure.datafy/nav when:
For pure JSON→EDN responses with no lazy navigation, the metadata convention above is sufficient. Revisit when adding related endpoints (e.g., navigating from a circuit ID to circuit details).
deft provides defrecord-like constructors backed by plain maps + Malli schemas + multimethod dispatch. Consider when entities need behavioral polymorphism (protocol implementations, multimethod dispatch). Skip for pure data entities.
Put schemas in their own namespaces, separate from coercion logic. Consumers can depend on schemas for validation, generative testing, or documentation without pulling in the transformation machinery.
mylib.pricing ;; coercion functions, API helpers
mylib.pricing.schema ;; coerced entity schemas (the public contract)
mylib.pricing.schema.raw ;; raw API schemas (boundary validation)
Most consumers only need the coerced schemas. The raw schemas are an implementation detail — useful at the boundary but rarely needed by downstream code.
schema.raw namespace)->entity) that:
{:ns/raw original} metadataschema namespace)raw-things for the raw data, things for coerced:tick/beginning, :tick/end) directly on entity maps that represent time spansCan you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |