~ / track D / clojure in practice

Designing libraries that compose

Intermediate

Most Clojure code is glue: the part of your program that calls libraries written by other people. Some fraction of it eventually becomes a library — either published, or merged into another internal repo. The design choices that make a library pleasant to call from the outside are different from the ones that make an application pleasant to read from the inside. This page is about the small set of habits that travel well.

A library is its public surface

Two namespaces, three functions, one record. That's the shape most libraries should aim for. The rest of the code exists, but lives behind ^:private or in impl namespaces the docs don't mention.

A small public surface buys two things:

  • You can change the implementation without breaking users. Anything not in the docs is fair game to refactor.
  • Users can hold the API in their head. A library with one obvious entry point gets used; a library with thirty exported helpers gets studied, then avoided.

The discipline that makes this work:

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

d‌e‌f‌n‌- is defn ^:private. Use it freely. The cost of over-publishing is much higher than the cost of under-publishing — a private fn can always be promoted later.

Take data, return data

The single best convention Clojure libraries share is data in, data out. Functions take plain maps and collections, return plain maps and collections, and avoid wrapping inputs in framework-specific objects.

This pays off three ways:

  1. Callers can use any existing Clojure tool to inspect the inputs and outputs — tap>, pprint, edn writers, the REPL.
  2. Tests don't need fixtures, only literal values.
  3. Bindings across language boundaries (Babashka, ClojureScript) just work.

A red flag: a library that requires you to wrap your domain values in its own records. Datomic's transactions are vectors; Ring's request is a map; Reagent's components are vectors. None of them invented a RequestWrapper. That's the bar.

Options as a map, not positional args

Functions with more than two "real" arguments quickly become unreadable at the call site. The Clojure convention is required args positional, options as a final map:

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

Two wins:

  • Order independence. Callers pass {:timeout-ms 10000} without remembering whether it was the third or fourth positional argument.
  • Forward compatibility. Adding a new option doesn't break old call sites — they just omit it and get the default.

Most Clojure libraries (clj-http, next.jdbc, reitit) take this shape. It's the boring-correct default.

Pure core, effectful edges

If a library does I/O — HTTP, files, databases — the effectful function should be a thin wrapper around a pure one. Users who want to test their own code can call the pure version with fake input; users who don't can call the effectful one and ignore the split:

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

This split is the single most testable shape for a library. The pure half is d‌e‌f‌t‌e‌s‌table with literal maps; the effectful half is one line and mostly delegation.

Errors as values are part of your API

The "expected failures" pattern from the [[error-handling]] lesson applies in spades to libraries. The result shape (nil, [:ok v]/[:error info], {:status … :reason …}) is part of the API — switching from "returns nil on missing" to "throws on missing" is a breaking change, even though the function signature didn't change.

Pick a shape, document it, and stay consistent across the whole library. clojure.core itself isn't perfectly consistent here, which is why a fresh library should be.

Version what users actually depend on

If you've never named something :internal or impl in a public-looking namespace, you will. The discipline:

  • Anything in a namespace named myapp.lib.impl.* or myapp.lib.internal.* is fair game to change without a major bump.
  • Anything in a namespace named myapp.lib.* and exported (not ^:private) is API. Breaking changes require a major version.
  • :added "1.2" metadata on new functions is cheap and answers a real question.

Most Clojure libraries follow this loosely; the ones that follow it strictly (Datomic, next.jdbc, reitit) earn the trust to make breaking changes when they need to.

Check yourself

? quiz

A library function `fetch` currently takes `(fetch url method headers timeout)` and you want to add a `retries` option. Why is `(fetch url {:method … :headers … :timeout-ms … :retries …})` a better next step than `(fetch url method headers timeout retries)`?

Exercise

Refactor the function notify below to follow the conventions on this page: keep message as a required positional arg, move the rest into an options map with defaults, and split the effectful "send the email" from the pure "decide what to send."

(defn notify [message recipient subject from priority retries]
  ;; … sends the email …
  )
loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
 status: new