How to build resource-oriented HTTP APIs in Clojure with Ring and Compojure using a current deps.edn-first workflow and focused middleware.
Ring and Compojure still make sense together when you want a small, explicit HTTP stack in Clojure. Ring gives you the request and response abstraction. Compojure gives you a concise routing layer on top. That combination remains useful for real systems, especially when the team wants control and readability more than a full framework.
The freshness fix for this topic is important, though: new examples should not start from a Leiningen-and-site-defaults template as though that were the universal answer. For APIs in 2026, a deps.edn-first setup and focused middleware choices are the more durable teaching path.
Ring is not a framework in the “do everything for you” sense. It is a minimal HTTP model. Requests are maps. Responses are maps. Middleware is ordinary function composition. That small surface area is why Ring has lasted so well.
Compojure is similarly narrow. It is a small routing DSL for Ring handlers. It does not try to be your serializer, your validation system, your database layer, or your API governance strategy.
That is the right mental model:
The official Ring and Compojure repositories both publish deps.edn examples, so a modern starting point can be very small:
1{:deps {org.clojure/clojure {:mvn/version "1.12.0"}
2 ring/ring-core {:mvn/version "1.15.3"}
3 ring/ring-jetty-adapter {:mvn/version "1.15.3"}
4 compojure/compojure {:mvn/version "1.7.2"}}}
That gives you a clean baseline without smuggling in browser-oriented defaults that a JSON API does not actually need.
The example below deliberately returns EDN rather than automatically wiring JSON middleware. That keeps the example focused on Ring and Compojure themselves. In production, you would add explicit serializers or JSON middleware appropriate to your stack.
1(ns users.api
2 (:require [compojure.core :refer [defroutes GET POST PUT DELETE]]
3 [compojure.route :as route]
4 [ring.adapter.jetty :refer [run-jetty]]
5 [ring.middleware.keyword-params :refer [wrap-keyword-params]]
6 [ring.middleware.params :refer [wrap-params]]
7 [ring.util.response :as response]))
8
9(defonce users
10 (atom {1 {:id 1 :name "Ada" :role "admin"}}))
11
12(defn edn-response [value]
13 (-> (response/response (pr-str value))
14 (response/content-type "application/edn; charset=utf-8")))
15
16(defroutes routes
17 (GET "/users" []
18 (edn-response (vals @users)))
19
20 (GET "/users/:id" [id]
21 (if-let [user (get @users (parse-long id))]
22 (edn-response user)
23 (-> (edn-response {:error :not-found})
24 (response/status 404))))
25
26 (POST "/users" [name role]
27 (let [id (inc (apply max 0 (keys @users)))
28 user {:id id :name name :role role}]
29 (swap! users assoc id user)
30 (-> (edn-response user)
31 (response/status 201))))
32
33 (PUT "/users/:id" [id name role]
34 (let [id (parse-long id)]
35 (if (contains? @users id)
36 (do
37 (swap! users assoc id {:id id :name name :role role})
38 (edn-response (get @users id)))
39 (-> (edn-response {:error :not-found})
40 (response/status 404)))))
41
42 (DELETE "/users/:id" [id]
43 (let [id (parse-long id)]
44 (if (contains? @users id)
45 (do
46 (swap! users dissoc id)
47 (response/status (response/response "") 204))
48 (-> (edn-response {:error :not-found})
49 (response/status 404)))))
50
51 (route/not-found "Not found"))
52
53(def app
54 (-> routes
55 wrap-keyword-params
56 wrap-params))
57
58(defn -main []
59 (run-jetty #'app {:port 3000 :join? false}))
This example teaches the right boundary:
Most REST mistakes in small Clojure APIs are not about Ring or Compojure. They are about resource design. Teams often build endpoints around UI actions instead of durable resources.
Prefer:
/users/users/:id/orders/orders/:idBe careful with:
/create-user/do-checkout/run-reportAction-style endpoints are sometimes necessary, but they should be deliberate exceptions, not the default vocabulary of the API.
One of the most common outdated examples is wrapping an API in site-defaults as though browser session defaults were the correct starting point. For APIs, that is usually too broad.
Instead, choose middleware by responsibility:
Focused middleware composition keeps the stack understandable. Wide bundles of defaults often make debugging harder because you stop knowing what is actually in the pipeline.
Ring and Compojure do not remove the need for API discipline. A strong production API still needs:
That is why it helps to think of Ring and Compojure as infrastructure, not architecture. They give you a strong HTTP foundation, but the real API quality comes from the decisions layered above them.
sequenceDiagram
participant Client
participant Middleware
participant Router
participant Handler
participant Store
Client->>Middleware: HTTP request
Middleware->>Router: normalized Ring request map
Router->>Handler: matched route and params
Handler->>Store: domain read or write
Store-->>Handler: result
Handler-->>Middleware: Ring response map
Middleware-->>Client: HTTP response
The key thing to notice is that middleware and handlers both work on the same Ring request/response model. That consistency is why the ecosystem remains composable.
Ring and Compojure are a strong fit when you want:
They are a weaker fit when the team expects the routing layer itself to solve validation, schema evolution, documentation, and every other API concern automatically.
deps.edn, not from old Leiningen templates by default.