REST APIs with Ring and Compojure

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.

What Ring and Compojure Actually Own

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:

  • Ring owns the HTTP abstraction
  • Compojure owns route declaration
  • your application still owns validation, persistence, serialization, and domain rules

A Current Minimal Setup

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.

A Small Resource-Oriented API

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:

  • routes are explicit
  • status codes are explicit
  • middleware is explicit
  • the handler surface remains small and inspectable

Designing Better HTTP Resources

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/:id

Be careful with:

  • /create-user
  • /do-checkout
  • /run-report

Action-style endpoints are sometimes necessary, but they should be deliberate exceptions, not the default vocabulary of the API.

Middleware Should Be Focused

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:

  • request parameter parsing
  • keywordization
  • authentication
  • structured logging
  • error translation
  • JSON or EDN serialization
  • request IDs and tracing

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.

Validation, Serialization, and Errors Are Still Your Job

Ring and Compojure do not remove the need for API discipline. A strong production API still needs:

  • request validation
  • response schema discipline
  • consistent error shapes
  • authentication and authorization boundaries
  • observability around latency and failure modes
  • test coverage for route behavior and serialization contracts

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.

Request Flow

    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.

When This Stack Is A Good Fit

Ring and Compojure are a strong fit when you want:

  • a small HTTP surface
  • explicit control of middleware
  • a readable routing DSL
  • easy composition with your own domain code

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.

Key Takeaways

  • Ring remains the core HTTP abstraction, and Compojure remains a concise routing layer on top of it.
  • Start new examples from deps.edn, not from old Leiningen templates by default.
  • Treat middleware as focused composition, not a bag of defaults.
  • Build APIs around resources, not around UI actions disguised as URLs.
  • Serialization, validation, and error design still need explicit engineering.

Ready to Test Your Knowledge?

Loading quiz…
Revised on Thursday, April 23, 2026