The Ring Specification
Ring is the foundational Clojure web spec, analogous to Python's WSGI or Ruby's Rack, that defines a simple contract: an HTTP request becomes a plain Clojure map, and a handler is just a function that takes that map and returns a plain Clojure map describing the response. Because everything is data, you can compose, test, and debug web apps with the same ordinary tools you'd use on any Clojure data structure, with no framework-specific request or response classes required.
Cricket analogy: Ring's request-as-a-map contract is like the ICC's standardized scorecard format that every ground's scoring software must output identically, so any analysis tool can read it without caring which stadium generated it.
Handlers, Requests, and Responses
A Ring request map includes keys like :request-method (:get, :post, and so on), :uri, :headers, :query-string, and :body (an InputStream), and a minimal handler simply reads those keys and returns a response map with :status, :headers, and :body. Because a handler is "just a function," you can unit test it directly by calling it with a hand-built request map and asserting on the returned map, with zero HTTP server involved.
Cricket analogy: A Ring handler pattern-matching on :request-method is like an umpire reading just the delivery type off a simple signal sheet and responding with one ruling, testable by handing the umpire a hypothetical delivery card with no actual match being played.
(defn handler
[request]
(if (= (:request-method request) :get)
{:status 200
:headers {"Content-Type" "text/plain"}
:body "Hello, Ring!"}
{:status 405
:headers {}
:body "Method not allowed"}))
;; Pure function -- testable with no server running:
(handler {:request-method :get :uri "/"})
;; => {:status 200, :headers {"Content-Type" "text/plain"}, :body "Hello, Ring!"}Middleware
Middleware in Ring is just a higher-order function that wraps a handler and returns a new handler, letting you layer cross-cutting concerns like wrap-json-response, wrap-params, or wrap-session around your core logic without touching it. Because middleware composition is plain function composition, you control ordering explicitly — wrapping wrap-params around wrap-keyword-params only makes sense if params are parsed before you try to keywordize their keys.
Cricket analogy: Middleware wrapping a handler is like a fielding-restriction rule during the powerplay wrapping the base game rules — it doesn't rewrite how batting works, it just adds a constraint layer around the core handler for a defined period.
The order middleware is applied in code is the reverse of the order requests actually flow through at runtime. If you write (-> handler wrap-a wrap-b), a request hits wrap-b first, then wrap-a, then the handler, because wrap-a is the outermost wrapping function applied last.
Routing with Reitit or Compojure
Raw Ring only gives you a single handler function, so real applications add a routing library — Compojure's (GET "/users/:id" [id] ...) macros, or the newer, data-driven Reitit, which defines routes as plain vectors of [path handler-or-opts] and supports both Ring and pluggable coercion and swagger/OpenAPI generation out of the box. Reitit's data-oriented routes are easier to introspect and generate API documentation from than Compojure's macro-based routes, which is why most new Ring projects since the late 2010s default to Reitit.
Cricket analogy: Reitit's data-driven routes, plain vectors, are like a fixture list stored as a structured spreadsheet any analysis tool can query, while Compojure's macro-based routes are like fixtures announced only via a commentator's spoken macro, harder to introspect programmatically.
;; Reitit: routes as plain data
(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))})}]])))
;; Compojure: routes as macros
(require '[compojure.core :refer [defroutes GET]])
(defroutes app-routes
(GET "/" [] "home")
(GET "/users/:id" [id] (str "user " id)))Running a Server with Jetty
Ring ships an adapter-agnostic design; the most common production adapter is ring-jetty-adapter's run-jetty function, which takes your handler, ideally passed as a Var via #'app so code reloads without restarting the server, and an options map like {:port 3000 :join? false}. Wrapping your top-level handler with wrap-reload during development lets route changes take effect without restarting the JVM, which is one of the biggest REPL-driven-development wins Ring users rely on daily.
Cricket analogy: Passing a handler Var (#'app) to run-jetty is like a scoreboard operator reading live from the official scoring feed rather than a printed snapshot — the Var always reflects the latest code, just as the live feed always reflects the latest ball bowled.
Passing the handler function's value directly to run-jetty, instead of referencing it via a Var with #'app, means Jetty captures a snapshot of the handler at startup — redefining your handler at the REPL afterward won't be picked up until you restart the server.
- Ring defines requests and responses as plain Clojure maps, and a handler is just a function from request map to response map.
- Core request keys include :request-method, :uri, :headers, :query-string, and :body; core response keys are :status, :headers, and :body.
- Middleware is a higher-order function wrapping a handler; application order in code is the reverse of runtime request flow.
- Reitit's data-driven routes (plain vectors) are easier to introspect and generate docs from than Compojure's macro-based routes.
- ring-jetty-adapter's run-jetty is the standard way to serve a Ring app in production, typically with {:port N :join? false}.
- Always pass the handler as a Var (#'app) to run-jetty so REPL redefinitions take effect without restarting the server.
- wrap-reload is the key development middleware that lets route/handler changes apply live during REPL-driven development.
Practice what you learned
1. In Ring, what data type is an HTTP request represented as when it reaches a handler?
2. What keys are required in a minimal Ring response map?
3. If you write (-> handler wrap-a wrap-b), in what order does an incoming request actually pass through the middleware at runtime?
4. What is the main advantage Reitit's routing offers over Compojure's macro-based routes?
5. Why should you pass a handler to run-jetty as a Var (e.g., #'app) rather than as a plain function value during development?
Was this page helpful?
You May Also Like
Clojure Best Practices
A practical guide to writing idiomatic Clojure: function and namespace design, choosing the right state primitive, structured error handling, and readable threading pipelines.
Clojure Quick Reference
A scannable cheat sheet of core Clojure syntax, everyday sequence and map functions, the four state/concurrency primitives, and the project tooling and REPL commands used daily.
Clojure Interview Questions
What Clojure interviews actually probe — core language fundamentals, concurrency reasoning, common live-coding patterns, and tooling/JVM interop knowledge — with the reasoning behind common answers.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics