~ / track D / clojure in practice
Designing libraries that compose
IntermediateMost 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:
defn-clojure.core/defn-Like defn but the var is private to its namespace.view on clojuredocs → 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:
- Callers can use any existing Clojure tool to inspect the inputs and
outputs —
tap>,pprint, edn writers, the REPL. - Tests don't need fixtures, only literal values.
- 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:
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:
This split is the single most testable shape for a library. The pure half is deftestclojure.test/deftestDefine a unit test.view on clojuredocs →able 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.*ormyapp.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 …
)