~ / track C / clojure ecosystem
Web stack: Ring and Reitit
IntermediateecosystemA Clojure web app is just a function from request map to response
map. That's the entire Ring contract. Routing, middleware,
content-negotiation — everything else is composition on top of that
function. The modern stack is Ring (the protocol) + Reitit
(data-driven routing) + Jetty/http-kit/Aleph (the server). The handler
is the imperative shell in Functional core, imperative shell;
Malli schemas attach to :parameters for
boundary coercion; Error handling documents the
full Handler golden path (validate → core → status); and
Composing applications with components
passes the system map into each handler.
Ring in one example
(defn handler [request]
{:status 200
:headers {"Content-Type" "text/plain"}
:body "hello"})
(require '[ring.adapter.jetty :as jetty])
(jetty/run-jetty handler {:port 3000 :join? false})That's a full Clojure web server. Request is a map (:request-method,
:uri, :headers, :body...), response is a map (:status,
:headers, :body). Nothing more.
Middleware is function composition
Middleware is a function handler → handler:
(defn wrap-logging [handler]
(fn [req]
(println :req (:uri req))
(let [resp (handler req)]
(println :resp (:status resp))
resp)))
(def app
(-> handler
wrap-logging
(ring.middleware.json/wrap-json-body {:keywords? true})
ring.middleware.json/wrap-json-response))->clojure.core/->Thread the value through forms by inserting it as the first arg.view on clojuredocs → ([[threading-macros]]) reads top-to-bottom: every request flows
through wrap-logging first, then wrap-json-body, then the handler;
the response unwinds back through the same middleware in reverse. Pure
function composition.
Reitit — data-driven routing
(require '[reitit.ring :as ring])
(def app
(ring/ring-handler
(ring/router
[["/" {:get (fn [_] {:status 200 :body "home"})}]
["/users/:id"
{:get (fn [{:keys [path-params]}]
{:status 200 :body (str "user " (:id path-params))})
:post {:parameters {:body {:name string?}}
:handler (fn [_] {:status 201})}}]])))Routes are data — a vector of [path data]. That data is just a
map; the framework reads :get, :post, etc. Because routes are data
you can:
- Inspect them at runtime (
reitit.core/match-by-path) - Generate OpenAPI docs from them automatically
- Apply per-route middleware via
:middlewarein the data - Coerce parameters by attaching specs/malli schemas (
:parameters)
Coercion
(require '[reitit.ring.coercion :as rrc]
'[reitit.coercion.malli])
(def app
(ring/ring-handler
(ring/router
["/echo"
{:post {:parameters {:body [:map [:name :string]]}
:handler (fn [{{:keys [body]} :parameters}]
{:status 200 :body body})}}]
{:data {:coercion reitit.coercion.malli/coercion
:middleware [rrc/coerce-request-middleware
rrc/coerce-response-middleware]}})))If the request body fails the [[malli]] schema, Reitit returns 400 with
an explanation before your handler runs. Same idea with clojure.spec.
Comparison: Compojure vs Reitit
| Compojure | Reitit | |
|---|---|---|
| Style | Macro DSL — defroutes, GET, POST | Plain data (vectors and maps) |
| Reflection | Limited (macro-defined) | Full — routes are introspectable values |
| Coercion | Manual | First-class (spec/malli) |
| Docs | None built-in | OpenAPI/Swagger from the data |
| Performance | Slower routing | Hash-trie based, very fast |
| Default for new code | Less common today | Modern default |
Compojure is still common in older codebases. Reitit wins greenfield projects because routes-as-data composes with everything else.
The full request lifecycle
client → Jetty (Java thread) → Ring middleware stack
↓
router (Reitit)
↓
coercion (malli/spec)
↓
handler (your fn)
↓
response map
↓
middleware unwinds → clientEach step is just (fn [req] resp). The whole app is one function you
can call directly from tests — no HTTP needed.
Servers compared
| Server | Concurrency model | Notes |
|---|---|---|
Jetty (ring-jetty-adapter) | Thread-per-request | Most common; battle-tested |
| http-kit | Async / event-loop | Very fast for many idle connections (WebSockets, SSE) |
| Aleph | Netty-based, async | Manifold deferreds; lower-level, very fast |
Sun's HttpServer (ring-httpserver) | Threads | Tiny footprint; toy use |
Default to Jetty for typical request/response workloads — it's the most documented and battle-tested adapter. Reach for http-kit when you need WebSockets or thousands of idle long-poll connections on modest hardware, and Aleph when you want fully async streaming on Netty with Manifold deferreds.
In a Clojure service
Reitit routes are data you can inspect, document, and test. Attach a Malli
schema to :parameters and coercion runs before your handler — malformed
bodies never reach domain logic. The handler receives coerced values under
:parameters, calls pure functions, and returns a response map. Because the
whole app is (fn [req] resp), you can invoke it from clojure.test with
a request map — no Jetty required for unit tests of the routing layer.
(def routes-sketch ;; sketch
[["/orders"
{:post {:parameters {:body [:map
[:customer :string]
[:items [:vector [:map
[:sku :string]
[:qty pos-int?]]]]]}
:handler (fn [{{:keys [body]} :parameters}]
{:status 201 :body (create-order body))}}]}])Try it
Real-world
| Library | Use |
|---|---|
ring/ring-core, ring/ring-jetty-adapter | The base contract + a server |
metosin/reitit | Routing + coercion + OpenAPI |
metosin/malli | Schemas for coercion ([[malli]]) |
metosin/muuntaja | Format negotiation (JSON, EDN, Transit) |
ring-cors | CORS middleware |
buddy-auth, clj-jwt | Auth middleware |
aleph | Netty server with Manifold for streaming/WebSocket |
http-kit | Async server with built-in WebSocket support |
clj-http / hato | HTTP client (for calling other services) |
Check yourself
? quiz
In Ring, what type is a middleware?
Exercise
Write a Reitit app with two routes:
GET /health→{:status 200 :body "ok"}.POST /addaccepting{:a int :b int}(use malli coercion); responds with{:sum (+ a b)}.
Now add a middleware wrap-timing that measures wall-clock time per
request and adds an X-Elapsed-Ms header to the response. Wire it
globally via :middleware in the router options. Verify a failing
malli coercion (e.g. {:a "x"}) returns 400 without invoking your
handler.