On Interactive Development
by Laurence Chen
When I was a child, my sister caught chickenpox. Instead of isolating us, our parents let us continue playing together, and I ended up getting infected too. My father said, “It’s better to get chickenpox now—it won’t hurt you. Children aren’t just miniature adults.” This saying comes from pediatric medicine. While children may resemble adults on the outside, their internal physiology is fundamentally different. Some illnesses are mild for children but can hit adults much harder—chickenpox being a prime example.
I often think of this phrase, especially when reflecting on Clojure’s interactive development model: interactive development feels natural and smooth in small systems, but without deliberate effort, it often breaks down as the system grows. Just as children and adults must be treated differently, small and large systems have fundamentally different needs and designs when it comes to interactive capabilities.
Let me start by defining interactive development:
Interactive development is a development model that allows developers to modify, execute, and observe arbitrary small sections of code in a running system. This enables developers to obtain input/output behavior of that code and explore related structures, thereby supplementing or verifying the understanding gained from reading the source code.
Benefits of interactive development:
- It’s easy to enter a state of flow, since developers can quickly verify new code and receive meaningful feedback.
- It supports thought processes—developers can choose to understand complex logic either by reading source code or by observing input/output behavior in a black-box manner, corresponding to what Out of the Tar Pit classifies as informal reasoning about the code and testing, respectively.
- It partially replaces unit testing.
- It partially replaces integration testing.
Despite these obvious benefits, interactive development is one of the first things to erode as systems evolve.
Reason for erosion #1: Tight coupling between components and system
Without specific attention to interactive development, system initialization often tightly couples all components together. A common pattern is to load all config files, initialize databases, background schedulers, set up web routes, and assemble all components into a large object during startup. In such cases, any change to a component requires restarting the entire system for the changes to take effect.
This creates a problem: if I just want to tweak a setting—say, changing a port from 8080 to 8089—I’d have to restart the entire system. And system startup times can be slow enough to disrupt flow.
The solution is to enable local control over each component’s lifecycle, providing individual start/stop functions for each service. Popular Clojure lifecycle management libraries like mount, integrant, and makina support this.
The benefit of such design is that during interactive development, you can freely stop a component (e.g., just shut down :web
), observe behavior after changes, and restart it individually.
Reason for erosion #2: Tight coupling between business logic and services
Some components, like web servers or schedulers, are expensive to restart—often taking several seconds. If business logic is tightly coupled to these services, even after re-evaluating code in the REPL, behavior won’t immediately reflect the change because the service is still running old logic.
This can be addressed by decoupling business logic from services using vars.
In many Clojure web applications, you’ll see this pattern:
(def web-handler ...)
(run-jetty #'web-handler {:port 8080})
In Clojure, #'
is a var reference. It ensures the system accesses the variable indirectly during runtime. So even if web-handler
is redefined later, Jetty will automatically use the new version. There’s no need to restart the server, because it’s bound to the var, not the original function.
This technique applies to cron jobs too. For example:
(defn job-handler [n] (prn "job " n))
(schedule-task! {:handler #'job-handler
:tab "/5 * * * * * *"})
As long as schedule-task!
binds to #'job-handler
and not the function body, you can redefine and re-evaluate job-handler
without re-registering the whole job. This significantly shortens feedback loops and improves interactive development efficiency.
Reason for erosion #3: SQL queries
When using SQL databases, interactive development usually requires:
- The ability to compose SQL queries programmatically.
- The ability to inspect the raw SQL query from the corresponding function.
Libraries like YeSQL make these hard to achieve, because:
- SQL is stored as strings in external files, so it can’t be composed.
- Functions are generated via macros, making it hard to use “go to definition” in interactive development and to trace SQL queries from function names.
A better approach is using a data-driven library like HoneySQL, which allows SQL queries to be expressed as Clojure maps:
(def q {:select [:id :name]
:from [:users]
:where [:= :status "active"]})
You can then inspect the query string by calling (hsql/format q)
.
Reason for erosion #4: Web handlers, routers, interceptors
If you use Ring to develop web apps, interactive development is very intuitive:
- Requests are Clojure maps.
- Responses are Clojure maps.
- Handlers are just plain functions.
You can test handlers like this:
(my-handler
{:uri "/new"
:request-method :get})
However, things get more complex with webhook handlers. These often need to extract raw payloads from (:body request)
, and the body must be a java.io.InputStream
. This complexity can hinder interactive development.
A good workaround is ring-mock:
(require '[ring.mock.request :as mock])
(def req
(-> (mock/request :post "/webhook")
(mock/header "Content-Type" "application/json")
(mock/header "x-signature" "sig-1234")
(mock/body json-body)))
(webhook-handler req)
Other interactive development blockers are similar to this ring-mock example: when we’re unfamiliar with a library, we may miss tools that enable interactive work. For instance, with reitit (a router), you can use match-by-path
to check if a URI routes correctly. With sieppari (an interceptor library), you can use execute
to observe how a set of interceptors behaves given a request.
Reason for erosion #5: New dependency hot-reload
By default, adding a new library to deps.edn
does not make it available in the running REPL session.
I recommend Launchpad as a solution. It handles hot-reloading of dependencies automatically—no need to remember or invoke specific hot-reload functions.
Reason for erosion #6: Editor integration
If your editor has strong REPL integration—or even lets you modify it using Lisp—you can take interactive development to another level.
It wasn’t until I’d used Conjure for quite a while that I realized its full potential. For instance, I used to believe all testing had to be done via shell. But Conjure lets you run the test under the cursor with <localleader> tc
.
Another hidden gem in Conjure is pretty-printing nested EDN values. If you use clojure.pprint/pprint
, Conjure prefixes each line with ; out
, marking it as output. If you don’t want this, you can use (tap> variable)
to send it to the tap queue, then <localleader> vt
to display the queue, which will auto pretty-print without ; out
.
There are two classes of editor-integrated commands that I believe still hold great potential:
- Do something based on what’s under the cursor.
- Navigate somewhere based on what’s under the cursor.
Conclusion
Interactive development isn’t just a productivity tool—it’s a way of working in active dialogue with the system. It turns programming into something playful: through cycles of trial, observation, and adjustment, we refine both our understanding and design. The quality of this dialogue depends heavily on system architecture, tooling, library choices, and editor integration.
But just as “children aren’t miniature adults,” interactive development may feel natural in small systems, yet as systems grow, maintaining it takes conscious effort. Otherwise, it quietly slips away amidst tighter coupling, increased complexity, and growing dependencies.
The investment is worth it. It deepens our system understanding, makes flow states more accessible, gives test-oriented functions new life, transforms the editor into a rich interface—and helps us design truly decoupled systems.