How Clojure microservices choose between HTTP, gRPC, and event streaming, and which communication patterns fit synchronous versus asynchronous boundaries.
Communication between Clojure microservices is not mainly a library choice. It is an architecture choice about coordination, latency, failure handling, and how much coupling a team is willing to accept. The healthy order of thinking is:
Older pages often go the other way around and start by naming a few Clojure libraries. That tends to produce accidental architecture.
Use synchronous communication when the caller truly needs an immediate answer:
Use asynchronous communication when the real requirement is durable propagation rather than immediate coordination:
The biggest mistake is to use synchronous calls for work that should have been modeled as delayed propagation, or to use eventing for queries that really need immediate consistency.
For many service-to-service interactions, plain HTTP with JSON remains the best default because it is:
In modern Clojure code, that does not mean you must default to an older synchronous client library. For simple internal clients, plain Java interop with the JDK HTTP client is often enough:
1(ns myapp.clients.catalog
2 (:import [java.net URI]
3 [java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers]))
4
5(def client (HttpClient/newHttpClient))
6
7(defn fetch-item [id]
8 (let [request (-> (HttpRequest/newBuilder
9 (URI/create (str "http://catalog.internal/items/" id)))
10 (.header "accept" "application/json")
11 (.GET)
12 (.build))
13 response (.send client request (HttpResponse$BodyHandlers/ofString))]
14 {:status (.statusCode response)
15 :body (.body response)}))
That example is intentionally boring. Boring is good when the boundary itself is already easy to reason about.
Libraries such as http-kit still make sense when you want a Clojure-friendly client/server surface with sync and async options. But the page should not teach “use clj-http” as though it were the timeless center of Clojure microservice communication.
gRPC is strongest when the boundary benefits from:
In Clojure, teams often use generated Java classes and stubs through interop instead of searching for a uniquely Clojure-native abstraction.
1(ns myapp.clients.inventory
2 (:import [io.grpc ManagedChannelBuilder]
3 [com.example.inventory InventoryServiceGrpc GetInventoryRequest]))
4
5(defn fetch-inventory [sku]
6 (let [channel (.. (ManagedChannelBuilder/forAddress "inventory.internal" 50051)
7 (usePlaintext)
8 build)]
9 (try
10 (let [stub (InventoryServiceGrpc/newBlockingStub channel)
11 request (.. (GetInventoryRequest/newBuilder)
12 (setSku sku)
13 build)
14 response (.getInventory stub request)]
15 {:sku (.getSku response)
16 :available (.getAvailableUnits response)})
17 (finally
18 (.shutdown channel)))))
The point is not that every Clojure team should prefer gRPC. The point is that when you do choose gRPC, Java interop is often the most practical path.
When services should evolve more independently, event streaming is often a better fit than direct request/response. Kafka is a common example because it gives you:
In Clojure, using the official Kafka Java client through interop is often clearer than teaching a thin wrapper as if the wrapper were the architecture.
1(ns myapp.events.orders
2 (:import [java.util Properties]
3 [org.apache.kafka.clients.producer KafkaProducer ProducerRecord]))
4
5(defn producer-props []
6 (doto (Properties.)
7 (.put "bootstrap.servers" "localhost:9092")
8 (.put "key.serializer" "org.apache.kafka.common.serialization.StringSerializer")
9 (.put "value.serializer" "org.apache.kafka.common.serialization.StringSerializer")))
10
11(defn make-producer []
12 (KafkaProducer. (producer-props)))
13
14(defn publish-order-created! [producer order-id payload]
15 (.send producer (ProducerRecord. "orders.events" order-id payload)))
This kind of communication trades immediate certainty for autonomy. That is the right trade only when the business process can tolerate it.
Sente is valuable for real-time browser/server and interactive channel use cases. It is not usually the first architecture choice for generic microservice-to-microservice communication. That distinction matters because older pages sometimes blur interactive application messaging and backend integration messaging into one category.
If the main boundary is between browser clients and a live application channel, Sente may be a strong fit. If the main boundary is between services coordinating domain work, HTTP, gRPC, and event streaming are more often the central decision set.
| Protocol or Pattern | Best Use | Main Strength | Main Trade-Off |
|---|---|---|---|
| HTTP/JSON | Immediate request/response APIs | Simplicity and debuggability | Tighter runtime coupling |
| gRPC | Strong contracts, polyglot, streaming | Typed schemas and efficient transport | More tooling and contract overhead |
| Kafka or event streaming | Decoupled propagation and replay | Loose coupling and durable events | Eventual consistency and harder debugging |
| WebSocket-style channels | Live interactive sessions | Bidirectional near-real-time updates | Not the general default for backend integration |
The right question is not “which one is modern?” The right question is “which failure mode and consistency model does this boundary need?”
No matter which protocol you pick, service communication needs a few disciplined rules:
Without those rules, the code can look elegant while the system behaves badly under pressure.
flowchart LR
A["Caller Service"] -->|HTTP or gRPC| B["Immediate Response Needed"]
A -->|Event Publish| C["Broker or Log"]
C --> D["Independent Consumer Service"]
The key thing to notice is that these are different coordination models, not just different wire formats.