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.
Stage 1: an ordinary HTTP decision
Section titled “Stage 1: an ordinary HTTP decision”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.
Stage 2: wait without blocking the worker
Section titled “Stage 2: wait without blocking the worker”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.
Stage 3: transform a reading stream
Section titled “Stage 3: transform a reading stream”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.
Stage 5: expose a streaming response
Section titled “Stage 5: expose a streaming response”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.
Stage 7: add a worker-local RTC client
Section titled “Stage 7: add a worker-local RTC client”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.
What the progression demonstrates
Section titled “What the progression demonstrates”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 the session without Hoplite
Section titled “Test the session without Hoplite”(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.