~ / track C / clojure ecosystem

edn and deps.edn

Basic

edn is Clojure's data interchange format — the subset of the language that's just data: numbers, strings, keywords, vectors, maps, sets, lists. It's what comes out of pr-str and what clojure.edn/read-string consumes. And deps.edn is the file that tells the Clojure CLI what your project depends on — itself a single edn map. Two halves of the same idea: the format you serialize values in, and the format you describe your project in.

edn at a glance

Every Clojure value that's just data is also valid edn. Round-tripping through a string is one call in each direction:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

pr-str writes the readable form. p‌r‌n, p‌r, and binding [*print-readably* true] all produce edn-shaped output that the reader can consume.

This is what makes config files, RPC payloads, log records, and database columns natural in Clojure: there's no schema layer between the value you have and the bytes you write. Save it, send it, read it back — the only edge case is "what if there's a tagged literal."

Tagged literals

A tagged literal is #tag value and lets edn carry types beyond the built-in ones. Two ship with every reader:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

#inst becomes a date/time value, #uuid becomes a UUID. The tag is the type; the value is whatever the reader does with the form that follows it.

You can register your own. Passing a :readers map to read-string keeps the reader scoped to that call — useful for safely accepting untrusted edn:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

In a real codebase you'd register money globally in data_readers.clj so every file that reads edn handles the tag uniformly.

When to use a tagged literal vs a map

Tagged literals are for things the codebase wants to treat as a type: money, durations, IDs, decimals. If the value is "just data" with no behavior, a map with namespaced keys is usually clearer:

;; Tagged literal — when you expect to dispatch on it
#money [30 "USD"]
 
;; Plain map — when it's just a record
{:price/amount 30 :price/currency "USD"}

Don't over-tag. Most domain entities are happier as maps.

Why not JSON?

edn does the same job as JSON, but with three concrete wins for Clojure:

  1. Sets and keywords as first-class values. No more "role": "admin" that has to be keywordd on the other side.
  2. Maps with anything as keys. A {[lat lng] :city} map round-trips cleanly; JSON would force you to stringify the coordinates.
  3. Tagged literals. Custom types without schemas, version negotiation, or out-of-band documentation.

The cost is that everything talking edn has to understand it — fine for JVM↔ClojureScript, less fine when one side is a Python service. The usual boundary rule applies: edn between Clojure services, JSON at the edge that talks to the rest of the world.

deps.edn — the project file

The Clojure CLI (clojure / clj) is configured by a deps.edn file — itself a single edn map describing dependencies, source paths, and named "aliases" for variant commands. A minimal one:

{:paths   ["src" "resources"]
 
 :deps    {org.clojure/clojure {:mvn/version "1.12.0"}}
 
 :aliases
 {:test {:extra-paths ["test"]
         :extra-deps  {io.github.cognitect-labs/test-runner
                       {:git/tag "v0.5.1" :git/sha "dfb30dd"}}
         :main-opts   ["-m" "cognitect.test-runner"]}
 
  :dev  {:extra-deps {nrepl/nrepl {:mvn/version "1.0.0"}}}
 
  :uberjar
  {:replace-deps {com.github.seancorfield/depstar {:mvn/version "2.1.303"}}
   :exec-fn      hf.depstar/uberjar
   :exec-args    {:jar "target/myapp.jar" :main-class "myapp.core"}}}}

The three pillars:

  • :paths — where source lives. Anything you want on the classpath at base.
  • :deps — your library dependencies. The coordinate is a map: Maven artifacts use {:mvn/version "x"}, Git deps use {:git/url "…" :sha "…"}, local paths use {:local/root "../other-lib"}.
  • :aliases — named modifiers you can stack on the command line. clj -M:test runs the test-runner; clj -M:dev:test merges both. Each alias can add deps, change paths, set main options, or swap dependency versions.

How dependency layers compose

There are three places deps.edn files merge:

  1. ~/.clojure/deps.edn — your user-wide tools (Reveal, Portal, an nREPL alias you want everywhere).
  2. Project deps.edn — the canonical file for this codebase.
  3. deps.local.edn (some workflows) or -Sdeps '{…}' on the CLI — one-off overrides.

The CLI computes the effective classpath from the merge and caches it. The implication: aliases are modifiers, not separate projects. clj -M:test -X my.ns/run runs the test alias's deps and paths, then calls my.ns/run — composition all the way down.

A practical use: read a config file

A common pattern: ship a config.edn, override per environment with prod.edn, deep-merge at startup, read the result through a typed accessor. Reading is one line:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

In production the text comes from (slurp "config.edn"). The point: no schema library is required to start. You can layer Malli or Spec on top later for validation, but the parse-and-use path is just read-string + g‌e‌t‌-‌i‌n.

Check yourself

? quiz

Which is **not** a reason to prefer edn over JSON between two Clojure services?

Exercise

Given the edn string below, parse it and return a vector of only the admin users' names, sorted alphabetically. Use clojure.edn/read-string, then plain seq operations — no schema library needed.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
 status: new