most read
Software Engineering
Why We Killed Our End-to-End Test Suite Sep 24
Product
Product Managers: what they do and why we need them Feb 15
Software Engineering
The value of canonicity Oct 30
Careers
We are building diverse teams with the most creative and innovative professionals for each position we open.
Written by: Arthur Fücher
I always wanted to contribute to Clojure. Not building a new library, but improving the language itself. Working on a programming language felt too hard to me, a senior software engineer.
During a mentorship at Nubank, I told Alex Miller that I hoped to reach that point someday. His response was roughly: “You can do that today.”
Not everyone has the opportunity to have a Clojure core contributor mentor them. Alex’s point was simple: the tickets are public, the contribution process is clear, and there are real problems to solve now.
The mentorship became: pick an open ticket, work on it, learn. And I worked on two Clojure core tickets. CLJ-2897, about dynamic library loading through prepl, merged and included in Clojure 1.13.0-alpha5. CLJ-1548, about primitive type hints in protocols, still in review when I presented this experience at Clojure/Conj 2026.
I expected the code to be the hardest part, and it was hard. But it wasn’t the hardest part: that turned out to be four questions. Questions that I had to answer before anyone would even look at my code.
1. What is actually wrong?
My first ticket involved Clojure’s prepl, a programmatic REPL. Like a conventional read-eval-print loop, it accepts expressions and evaluates them. Unlike a conventional REPL, it emits structured results. A developer can connect to a running process through a socket and evaluate forms..
Using
add-lib
, which add dependencies to a running REPL session, failed in prepl:That was an odd message to receive while using a REPL. The ticket identified the immediate cause: prepl did not bind *repl* to
true
.My first patch added the binding. It was a two-line change that addressed the ticket’s stated cause. I attached the patch. I felt great.
Then Alex pushed me to consider what the user wanted to do. They didn’t want a particular value for *repl*; they wanted to load a library.
I applied the patch and tested, the original error disappeared. But now another error message: Context classloader is not a DynamicClassLoader.
Same user. Same goal. Another error.
add-lib requires a DynamicClassLoader. Prepl hadn’t set one up.
My revised patch added both the *repl* binding and the classloader setup. Now, the prepl session could then add Cheshire, require
cheshire.core
, and call it.The first patch removed the reported error, while the second made the user’s task work.
I had even seen a reference to the related classloader issue, CLJ-2540, in the original ticket. I read it without understanding how central it was. Reading a ticket and absorbing a ticket are different things.
Check our job opportunies
2. Why does the current behavior exist?
Once the patch worked, I needed to understand why prepl lacked those pieces in the first place.
The explanation was historical. Prepl was created in 2018 by copying and modifying clojure.main/repl. But it didn’t keep the REPL’s DynamicClassLoader setup. Later, in 2023, clojure.main/repl gained the *repl* binding, but the change didn’t reach prepl.
Two implementations with related responsibilities had drifted apart. This was a consequence of how the code had evolved.Understanding this raised a bigger question: should fix the drift? Or the symptom of the drift?
My second ticket exposed a different kind of mismatch.
In Clojure, we have primitive type hints. It tells the compiler to use a primitive-aware invocation rather than boxing a value. With ordinary functions, that can be a useful optimization. But consider this protocol example from CLJ-1548:
Defining the protocol works. Extending it for strings works. Directly calling the function fails.
The reason is subtle. defprotocol accepts the primitive hint but doesn’t generate a corresponding primitive-specialized method. Yet the hint remains in the function’s :arglists metadata. When the compiler reads that metadata and selects a specialized invocation. In this case IFn$OLO, an interface the generated protocol function doesn’t implement.
The JVM cast fails before dispatch to the string implementation can happen. The function’s metadata promises a calling convention that the generated function cannot fulfill.
Clojure 1.12 already included a fix for a similar issue, about primitive return hints, CLJ-2698. But CLJ-1548 concerned primitive parameter hints, and understanding the difference mattered.
“Why does this happen” is not a curiosity question. It’s the only way to decide whether the fix belongs..
3. What has already been considered?
Both tickets contained more accumulated reasoning than I first appreciated.
The prepl ticket had pointed to CLJ-2540 all along. A triage process expanded the protocol ticket with the related return-hint precedent. and possible solutions. I wasn’t arriving at untouched problems. Other contributors had already done important investigative work.
And yet simply reading those notes wasn’t enough. My first prepl patch proved that I could read a clue and still underestimate it.
After the revised prepl patch worked, Alex encouraged me to implement alternatives. Not only think about them. And we ended up comparing three approaches:
The experiments mattered because they replaced an attractive architectural idea with concrete trade-offs. An approach can look elegant until it has to preserve existing exception handling, output structure, and other behavior that real clients expect.
In total, I wrote 126 lines of experimental code that wouldn’t be part of the accepted change. And I want to be clear: it wasn’t a waste. It was the cost of knowing different dimensions of the solution. You can’t know the simple option is correct until you’ve seen what the complicated ones actually cost.
4. What quietly rules most options out?
The approach that best prevented future prepl/REPL drift wasn’t selected.
Rebuilding prepl on top of clojure.main/repl offered the clearest reuse story. But getting the behavior right will need modifying repl itself. And repl has external users who may depend on details of its existing behavior. Changing it to fix prepl would impose risk on code that hadn’t experienced the original bug.
We chose the smaller, less disruptive patch. It solved dynamic library loading in prepl without claiming to solve future divergence. We documented that limitation rather than hidden it.
The protocol ticket made compatibility even more concrete. One proposed solution was to reject unsupported primitive hints at definition time.
To me it was obvious: Why let developers define something that will fail when called?
But the answer was: Because not every call fails.
Let’s check this example using a protocol function named add (the definition is omitted here):
In a direct call, the compiler sees the primitive-hint metadata and calls .invokePrim.. Passing the function as a value uses plain IFn.invoke, not invokePrim.
That means there may be code with primitive hints on protocol methods that has worked fine for years.Rejecting those definitions would break code that currently runs.
So, the alternatives had different consequences for existing users.
On both tickets I had more than one option that worked. What was decided between them had less to do with the code, and a lot to do with everything around it.
The final challenge: Explaining the reasoning
By this point, I had spent time reconstructing behavior, implementing alternatives. Ready for the next step: Screening/prescreening process. I thought I had done the difficult work.
Then Michael Fogus screened the protocol ticket and left six comments. None were about the code. They concerned the description: put the problem first, introduce context in the order a new reader needs it, and make the argument for the change easier to follow.
I rewrote the ticket’s explanation, but the patch stayed the same.
That feedback connected with something Peter Naur wrote in Programming as Theory Building (1985):
“[…]programming properly should be regarded as an activity by which the programmers form or achieve a certain kind of insight, a theory, of the matters at hand.”
And he says one more thing: the theory lives in people. It does not fit entirely in the text. So if the theory is in my head, and the decision is in someone else’s, how do I move it across?
The four questions I had been answering were ways of building a story. A story that moves a theory from one head to another.
The evolution of CLJ-2897 made the contrast visible. Its initial description had 82 words; the final one had 505. Alongside, were 9 lines of accepted code and 126 lines of experimental work written to be thrown away.
It shows that for a patch to move, the story, the theory had to be in the head of the person deciding. A ticket isn’t paperwork, it is the transfer of understanding.
What contributing to Clojure core taught me
The prepl patch was ready in August 2025 and reached Clojure 1.13.0-alpha5 in July 2026. The protocol ticket, opened in October 2014, was still in progress when I wrote this..
That pace was frustrating. And it’s slow for the exact reason I am sharing about this experience. Nobody there accepts code before they accept the theory.
I came into this experience prepared to write code. What I wasn’t ready for were the four questions. And the fifth thing: writing it down as a story that builds the theory in someone else’s head.
Those activities were the real contribution, alongside the code.
I still think about Alex telling me that I could work on Clojure today. He wasn’t promising a quick patch or an easy review. He was pointing out that I could start learning from a real problem immediately.
Want to contribute? Explore Clojure’s issue discussions, its ticket tracker, and the official contribution workflow. Choose a problem and investigate what people are actually trying to accomplish. Read the history. Try the alternatives. Explain what you learn.
You don’t need a mentor to ask what the user is actually trying to do.
You can do that today.
Check our job opportunies