Liking cljdoc? Tell your friends :D

Clojure API Entity Pattern

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).

Overview

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>}

Layer 1: Raw

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" ...} ...]}

Raw Malli schemas

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]]])

Raw accessor functions

(defn raw-curves [response] (get-in response [:body :data]))
(defn validate-raw [body] (m/explain RawPricingResponse body))
(defn success? [response] ...)

Layer 2: Coerced Entities

Transform raw data into idiomatic Clojure values:

Raw typeCoerced typeExample
String timestampjava.time.Instant (UTC)"2026-03-08T00:00:00-0800"2026-03-08T08:00:00Z
Time periodtick interval (:tick/beginning, :tick/end)start + duration → {:tick/beginning instant :tick/end instant}
String decimalBigDecimal"0.032176"0.032176M
String enumNamespaced keyword"Final":gridx.status/final
camelCase keyNamespaced keyword:intervalPrice:gridx.interval/price

Namespaced keywords

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

Coerced Malli schemas

(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]]])

Computed values

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

Metadata Convention

Every coerced entity attaches the original raw data as metadata:

(-> coerced-map
    (with-meta {:gridx/raw raw}))

This enables:

  • Debugging: always see what the API actually returned
  • Round-tripping: reconstruct the raw form if needed
  • Tool integration: Portal, Morse, Reveal can display raw alongside coerced
;; Access raw data from any coerced entity
(-> interval meta :gridx/raw :startIntervalTimeStamp)
;=> "2026-03-08T00:00:00-0800"

Time Handling

Choose the type based on what the timestamp means, not just what the API sends:

java.time.Instant (UTC) — for point-in-time events

Use 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 boundaries

Use 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"))

tick intervals — for time periods

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:

  • Use LocalDateTime when the API provides date + time without timezone (let consumers attach timezone if needed)
  • Use Instant when the API provides absolute timestamps with offset/timezone
  • Only assoc :tick/beginning / :tick/end when the source fields are non-nil (use cond->)
  • Mark them {:optional true} in the Malli schema
  • Keep the original date/time fields — tick keys are additive, not replacements
  • Add tick/tick as a dependency (currently {:mvn/version "1.0"})

General rules

  • Never assume a timezone in library code — the API gives offsets, not zones
  • Raw timestamp strings always available via :ns/raw metadata
  • Watch for non-standard offset formats (e.g., -0800 vs -08:00); use a custom DateTimeFormatter as needed
  • The library must work globally, not just for one market's timezone

When to Use Protocols

Plain functions (default)

For 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.

datafy/nav

Consider clojure.datafy/nav when:

  • The API response is opaque (not already pure data)
  • There are lazy relationships to navigate (foreign keys, pagination, related endpoints)
  • You're building interactive exploration tooling

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

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.

Namespace Organization

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.

Checklist for Applying This Pattern

  1. Define raw Malli schemas mirroring the JSON shape (in schema.raw namespace)
  2. Write coercion functions (->entity) that:
    • Convert strings to native types (Instant, BigDecimal, keywords)
    • Use namespaced keywords
    • Compute derived values
    • Attach {:ns/raw original} metadata
  3. Define coerced Malli schemas for the nice entities (in schema namespace)
  4. Provide both layers: raw-things for the raw data, things for coerced
  5. Keep raw accessor functions (success?, validate-raw) — the raw layer never goes away
  6. Use Instant for time — parse offsets/zones, store UTC
  7. Assoc tick interval keys (:tick/beginning, :tick/end) directly on entity maps that represent time spans
  8. Start with plain functions for computed values and operations
  9. Add protocols only when polymorphism is concretely needed

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close