Skip to content

Begin typing to search this documentation.

7. Progressive case studies

The following stages add complexity only when a new requirement demands it. They are architectural slices rather than a single copy-and-paste deployment; provider availability and experimental surfaces are called out at each boundary.

Begin with a pure function and a small handler:

(defn severity [reading]
(cond
(> (:value reading) 80) :critical
(> (:value reading) 65) :warning
:else :normal))
(defn health [_request]
{:status 200
:headers {"content-type" "application/json"}
:body "{\"ready\":true}\n"})

Register the handler Var in immutable application data. This stage has no coroutine, channel, or stream because it does not wait for anything and does not coordinate independent activity.

Maintenance payoff: domain classification can be tested without starting Nginx. Performance mechanism: the handler follows Hoplite’s prepared, synchronous response path.

Suppose a readiness response must wait briefly for a host event:

(defn readiness [_request]
(co/await (Host/call "nginx" "sleep" [10]))
{:status 200 :body "ready\n"})

Only the pending operation introduces suspension. The worker returns to its event loop and resumes the Hara continuation on completion.

Maintenance payoff: control flow remains sequential and the failure belongs to the returned asynchronous result. Performance mechanism: waiting does not reserve an operating-system thread per request.

When readings arrive over time, reuse the original domain function:

(def classified
(stream/map
(fn [reading]
(assoc reading :severity (severity reading)))
readings))
(def urgent
(stream/filter
(fn [reading]
(not (= :normal (:severity reading))))
classified))

No application queue has been introduced. Values are pulled only when the consumer demands them.

Stage 4: decouple bounded producers and consumers

Section titled “Stage 4: decouple bounded producers and consumers”

A source may receive short bursts while downstream encoding has variable cost:

(def inbox (async/from-stream urgent 64))
(defn consume-alerts []
(async/go
(fn []
(loop [processed 0]
(let [alert (co/await (async/take inbox))]
(if (nil? alert)
processed
(do
(co/await (persist-alert alert))
(recur (inc processed)))))))))

Capacity 64 is now explicit system policy. After the burst is absorbed, backpressure reaches the upstream pump.

hoplite.core/stream can mark a producer as a logical backpressured response:

{:status 200
:headers {"content-type" "text/event-stream"}
:body (h/stream encoded-events)}

This host contract is experimental. A production design must test slow-client behavior, cancellation, and closure against the exact Hoplite version. Use a provider response source instead when serving an already-authorized bounded native object.

Stage 6: supervise a process or socket protocol

Section titled “Stage 6: supervise a process or socket protocol”

Adapt the native process or socket to IStreamDuplex, add framing, and let Relay own request matching:

(def service
(relay/relay transport
(frame/line)
{:timeout-ms 2000}))

Application handlers can exchange values with service without knowing whether the implementation is a subprocess, Unix socket, TCP connection, or test transport. Process and socket creation remain deployment capabilities rather than arbitrary authority derived from a request value.

After application signalling completes:

(def transport (rtc/connect handle))
(def peer
(relay/relay transport
codec
{:timeout-ms 5000}))

The same Relay-facing application logic now runs over RTC. The opaque handle and transport must stay inside their Nginx worker. Use Nchan or another external service for signalling and cross-worker fan-out; do not place live RTC handles in shared application data.

Each layer solves one new problem:

Requirement Added abstraction
Transform present values Standard Hara function
Wait for one host event Promise/coroutine
Transform values over time IStream pipeline
Decouple bounded pacing Channel
Coordinate several events alts
Model bidirectional I/O IStreamDuplex
Add application protocol semantics Relay

Starting at the lowest sufficient layer reduces allocations and, more importantly, reduces the lifecycle states future maintainers must understand.

Complete example: one bounded session loop

Section titled “Complete example: one bounded session loop”

This small coordinator combines the previous stages without introducing Relay:

(defn run-session [readings shutdown output]
(async/go
(fn []
(loop [sent 0]
(let [[value source]
(co/await
(async/alts [readings shutdown]
{:priority false}))]
(cond
(= source shutdown)
(do (async/close output) sent)
(nil? value)
(do (async/close output) sent)
:else
(let [event (assoc value :severity (severity value))]
(if (co/await (async/put output event))
(recur (inc sent))
sent))))))))

The coroutine owns sent; other activities communicate through ports. Both shutdown and input EOF close the output, and a closed output ends the loop.

(Test/run
[{:name "session stops cleanly"
:test (fn []
(let [input (async/chan 1)
stop (async/chan 1)
output (async/chan 1)]
(async/offer stop :stop)
(run-session input stop output)))
:expected 0}])

Hoplite is needed at the HTTP or RTC boundary, not to test the state transition and channel behavior in the center.

Next: Application catalogue.