Learn how Ring models HTTP as request and response maps, how middleware wraps handlers, and how a request moves inward while the response moves back out through the pipeline.
Ring: The foundational Clojure web interface that treats an HTTP request as a map and a handler as a function from request to response.
Ring matters because it keeps web programming close to ordinary data transformation. A request comes in as a map. A handler returns a response map. Middleware wraps handlers and can inspect or transform both directions of the flow. Once you understand that pipeline, most of the Clojure web ecosystem becomes easier to reason about.
A basic Ring handler looks like this:
1(defn hello-handler [request]
2 {:status 200
3 :headers {"content-type" "text/plain; charset=utf-8"}
4 :body "hello"})
The request is just data. The response is just data. That simplicity is the main reason Ring has remained such a strong foundation for Clojure web work.
Because the boundary is just data plus functions, it is easy to:
That small surface area is one of Ring’s biggest strengths for long-lived applications.
It is also why Ring remains such a strong teaching model. Even when a team later adopts richer routers, async handlers, or higher-level abstractions, the core request/response contract still explains most of the system’s behavior.
The request map typically includes keys such as:
:request-method:uri:query-string:headers:params after parameter middleware runs:body for streaming or request payload handlingThe response map usually includes:
:status:headers:bodyThat contract is deliberately small. Frameworks add conveniences, but they still build on this shape.
The visual below shows the useful mental model: the request travels inward through middleware layers to the handler, and the response travels back outward through those same layers.
This is why middleware order matters. The outermost wrapper sees the request first and the response last.
One of the easiest mistakes for new Ring users is imagining middleware as a flat list of features. It is really nested function composition. That means:
Once that mental model is stable, debugging middleware order becomes much easier.
It also makes it easier to answer design questions that often get blurred together:
1(defn wrap-request-id [handler]
2 (fn [request]
3 (let [request-id (str (java.util.UUID/randomUUID))
4 response (handler (assoc request :request-id request-id))]
5 (assoc-in response [:headers "x-request-id"] request-id))))
Middleware is not a special magical layer. It is ordinary higher-order function composition around the handler. That makes it powerful, but it also means you need discipline about what belongs there.
That same model applies when the handler is asynchronous. The plumbing may differ, but the design question stays familiar: which layer owns request preparation, which layer owns the handler decision, and which layer shapes the response or error on the way back out?
The handler should usually own:
Middleware should usually own:
The cleaner that boundary stays, the easier it is to inspect the pipeline during debugging or code review.
This boundary is what keeps the Ring stack composable instead of mysterious.
1(def app
2 (-> hello-handler
3 wrap-request-id
4 wrap-params
5 wrap-keyword-params))
Read that from bottom to top for request setup and from top to bottom for response unwinding. The order becomes easier to understand once you picture the nesting rather than imagining a flat list of features.
Because both the request and response are just data, you can inspect the boundary at several levels:
That is one reason Ring-based systems are often easier to test incrementally than heavier frameworks that bury the web boundary in container internals.
Ring pipelines become confusing when:
Another common mistake is forgetting that some request keys only exist after specific middleware has run.
If the team cannot explain which wrappers run first and what contract they produce, the application is already relying on accidental composition.
Keep the handler focused on application behavior. Use middleware for cross-cutting concerns such as parsing, auth boundaries, request IDs, sessions, or response shaping. When debugging web behavior in Clojure, start by asking two questions: what did the request map look like at this layer, and which middleware wrapped the handler before it got here?