~ / track C / clojure ecosystem

Web stack: Ring and Reitit

Intermediate

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

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

-> ([[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 :middleware in 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

CompojureReitit
StyleMacro DSL — defroutes, GET, POSTPlain data (vectors and maps)
ReflectionLimited (macro-defined)Full — routes are introspectable values
CoercionManualFirst-class (spec/malli)
DocsNone built-inOpenAPI/Swagger from the data
PerformanceSlower routingHash-trie based, very fast
Default for new codeLess common todayModern 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 → client

Each step is just (fn [req] resp). The whole app is one function you can call directly from tests — no HTTP needed.

Servers compared

ServerConcurrency modelNotes
Jetty (ring-jetty-adapter)Thread-per-requestMost common; battle-tested
http-kitAsync / event-loopVery fast for many idle connections (WebSockets, SSE)
AlephNetty-based, asyncManifold deferreds; lower-level, very fast
Sun's HttpServer (ring-httpserver)ThreadsTiny footprint; toy use

Default to Jetty unless you need long-polling/WebSockets at scale.

Try it

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

Real-world

LibraryUse
ring/ring-core, ring/ring-jetty-adapterThe base contract + a server
metosin/reititRouting + coercion + OpenAPI
metosin/malliSchemas for coercion ([[malli]])
metosin/muuntajaFormat negotiation (JSON, EDN, Transit)
ring-corsCORS middleware
buddy-auth, clj-jwtAuth middleware
alephNetty server with Manifold for streaming/WebSocket
http-kitAsync server with built-in WebSocket support
clj-http / hatoHTTP client (for calling other services)

Check yourself

? quiz

In Ring, what type is a middleware?

Exercise

Write a Reitit app with two routes:

  1. GET /health{:status 200 :body "ok"}.
  2. POST /add accepting {: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.

 status: new