Stability by Design

1 month ago 3

I recently came across the following tweet from OneHappyFellow1:

I think I figured out what’s stressing me out about programming in dynamically typed languages:

It’s about always being unsure if using a library in a particular way is going to work and if minor version upgrades aren’t going to break your code.

— One Happy Fellow (@onehappyfellow) May 5, 2025

I found this tweet interesting because the language I use the most—Clojure—is both dynamic and yet the ecosystem has a very strong reputation for stability. Before diving into why exactly this is the case, allow me to present some evidence to justify this belief.

Are Clojure Libraries Stable?

I searched the Clojurians Slack for the word "stability," and out of 20 total posts on the first page, 8 are applauding the stability that Clojure brings. This slack is the main forum for Clojurians, and it includes discussions about various libraries, bugs, fixes, etc, therefore one would reasonably expect stability complaints to dominate the discussion. My search is obviously not a random sampling, but it should give you an idea of how much the community appreciates and celebrates stability.

As further evidence, consider the following two charts from A History of Clojure which detail the introduction and retention of new code by release for both Clojure and for Scala.

Clojure codebase—Introduction and retention of code Clojure codebase—Introduction and retention of code Scala codebase—Introduction and retention of code Scala codebase—Introduction and retention of code

While this doesn't necessarily translate to library stability, it's reasonable to assume that the attitude of the Clojure maintainers will seep into the community. And that assumption is true.

Let's look at the retention of code for various popular libraries. I selected the following libraries off the top of my head with three criteria: all have more than 500 stars and are in active use. I could have easily selected more.

xforms codebase—Introduction and retention of code xforms codebase—Introduction and retention of code

Component codebase—Introduction and retention of code Component codebase—Introduction and retention of code

Instaparse codebase—Introduction and retention of code Instaparse codebase—Introduction and retention of code

core.match codebase—Introduction and retention of code core.match codebase—Introduction and retention of code

Clearly library authors follow in the footsteps of the Clojure maintainers in this regard.

The last evidence I will give is anecdotal, but informative. I recently pushed an update to my fault tolerance library, Fusebox. This update came about because of a quirk in the retry utility. Whenever an exception is thrown, the exception is wrapped with metadata from the retry utility (e.g. how many retries happened) and then re-thrown.

A fusebox user, Martin Kavalar, recently requested that the exception be wrapped only if a retry actually happened. Not only was this a reasonable request, but this probably should have been the behavior from the beginning. I would even go so far as to call this a bug.

However, this is a bug that people have dealt with. They've written their code in such a way as to handle this bug. In other words, if I were to do the "right" thing and "fix" the bug, I would be breaking somebody else's code.

I told Martin as much, and he agreed without hesitation that we needed to find a solution that didn't break current users' code. This is not a normal interaction amongst software engineers—a breed infamous for their long, drawn out debates on the most minute of details. However, this is absolutely expected in the Clojure community.

Having given the evidence that, in practice, Clojure libraries tend to be very stable, the question becomes, "How is this possible?" On the surface, OneHappyFellow's reasoning makes sense: static types tell you when a breaking change has occurred, therefore they make the upgrade process much easier. The answer to this riddle has two parts.

What Makes Clojure Different?

In short, Clojure is—by convention—the most static dynamic language in existence.

In his twitter thread, OneHappyFellow argues a couple of smaller points: dynamic language serialization is busted, and monkey patching makes it a nightmare to pass objects across processes.

Consider a typical Javascript program. What is it comprised of? Objects, objects, and more objects. Members of those objects must be either introspected or divined. Worse, it's normal to monkeypatch those objects, so the object members may (or may not) change over time.

Now, consider a typical Clojure program. What is it comprised of? Namespaces. Those namespaces contain functions and data. Functions may be dynamically generated (via macros), but it is extremely rare to "monkeypatch" a namespace.2 If you want to know what functions are available in a namespace, you can simply read the source file.

What's more, you never serialize a namespace. That doesn't even make sense. Rather, you serialize data, and all Clojure data is serializable out of the box. In fact, it's serialized in the exact same way you write it in your source code, in a format called Extensible Data Notation (EDN). EDN even lets you to create custom tags that allow you to use custom constructors for data (e.g. for a datetime or a red-black tree).

Clojure data has another curious property that makes it more resilient to change: it's immutable. Once you're handed a hashmap, you can rest assured that nobody is going to tweak it without your knowing about it. It's impossible. Calls to assoc and update return a new hashmap rather than updating the existing hashmap.3 This means that when you pass data to another process or over the wire, you know the receiver is going to see exactly what you're seeing.

Lastly, I would like to draw your attention to how object members are named in dynamic languages. Typically (meaning, afaik always), they are simple, unadorned symbols like .name for a field or .doSomething() for a function. This naturally leads to ambiguity when, for example, you want to attach two names to something. Take, for instance, a user object. You start with user.name, but then realize you need the organization name. There are multiple options at this point, but one option is to rename user.name to user.username to accommodate for the new field, user.orgName. The problem is, this just created a breaking change.

In contrast, Clojure fields are typically given a namespace element. (This concept is related to but distinct from the namespaces we use to house functions.) So whereas user.name is common in Javascript, in Clojure it would look like {:user/name "OneHappyFellow"}. This allows for other kinds of "names" to be seamlessly integrated without breakage:

{:user/name "OneHappyFellow" :organization/name "OCaml Bois"}

OneHappyFellow's final point cuts much deeper, and it requires asking another question.

The worst case to me is when I refactor some code only to discover the way I went about it is fundamentally incompatible with some library I’m using.

— One Happy Fellow (@onehappyfellow) May 5, 2025

Why do Library Changes Break Programs?

Well, it's complicated, but to start, let's list out the reasons libraries make changes at all:

  • Security fixes
  • Bug patches
  • Enhancements

Of those changes, I think we can agree, at least in principle, that security fixes and bug patches should be non-breaking changes. Security fixes should basically ~never trigger a breaking change, and while bug patches might break things, they're typically minor, or they're marked wontfix.

Which leaves "enhancements" as the real booger.

"Enhancement" in and of itself don't mean breakage. After all, adding a new method to an object doesn't break anything. So what sort of "enhancements" do break things? Well:

  • renaming method (breaking)
  • renaming a type (breaking)
  • renaming a field (breaking)
  • renaming a package (breaking)
  • changing a method signature (depends)

It's not difficult to see that the only item in that list that has any semblance of validity is the last one. And here we come to the first hard truth in this article.

All this random renaming is killing us.

And all of this clamoring for static types? All of the insistence that static types "fix" this problem? It makes it worse. Static types "fix" this problem in the same way brooms fix the problem of you throwing your wine glasses on the kitchen floor. Of course, types make it more bearable, but they are not addressing the issue. Why are you hurling your wine glasses around your house in the first place?

Why are we renaming everything all the time?

Once you notice this trend, it's impossible to un-see. We get records out of the database, and what's the first thing we do? Rename its fields. We then run it through several transformation steps which will invariable rename them again. We then put it on the wire as JSON, and, of course, that requires that we rename them again. We then load them in our SPA and, well the names we got off the wire certainly won't do. Best rename them one more time.

It's insanity, and yet it is the world we created.

And, believe it or not, this is one major reason Clojure programs are so stinkin' stable. We don't do that. We don't rename things in our libraries, and when we pull data from somewhere, we do our very best to not rename things.

But renaming wasn't the only thing in the list of "stuff that causes breakages." What about changing method signatures? To address this, we need to further categorize things. What sorts of changes trigger changing a method signature?

  • Allowing—but not requiring—more data in the parameters (non-breaking)
  • Requiring more data in the parameters (breaking)
  • Requiring less data in the parameters (non-breaking)
  • Returning more data in the return (depends)
  • Returning less data in the return (breaking)

Of those items, two of them should cause no problems at all. Adding optional inputs to a function is obviously non-breaking.4 If you suddenly require fewer inputs—for example you figure out how to solve some partitioning dynamically—that need not be a breaking change. It's possible that this ends up as a breaking change (e.g. if your function once took three arguments, and now it only takes two), however, at least in principle, if you structure your code a certain way your users need not deal with this change.

Returning more data than before is somewhat nuanced. In principle, it shouldn't be a breaking change. However, if a function returns a new field, and then that data gets written directly to a database that doesn't have a column for that field, you might get an error (depending on the database). In general, if you make it a practice to select out the data you want prior to transferring it over the wire, you'll avoid any issues along these lines.

The two that you want to always avoid are requiring more inputs and returning less outputs. That's what breaks user code. If you avoid doing that, you will never break user code.

The question arises: What if you figure out a much better way to do things that requires more inputs and returns fewer outputs? The answer is simple: Make a new function for it. New functions do not break user code. New functions are simply new capabilities. They're great! You don't even have to edit that old code. You can just rewrite it from scratch!

Once again, types are a wonderful enabler in this situation. "Oh I don't have to worry about requiring more data to this function. The type checker will alert the user." That's true, but it doesn't change the fact that you needn't make this demand of your users in the first place.

Why are Clojure Libraries Stable?

In short, the Clojure ecosystem is abnormally stable because we avoid breaking things.

We don't rename namespaces. We don't rename functions. We don't rename keywords. We neither increase the data we demand, nor do we reduce the data we emit. If we think of a better way to do things, we create a new function, a new namespace, or even a whole new library.

It's worth noting that these activities are not free. Your mindset shifts when you're aware that you're going to be carrying some chunk of code for a long time. You become more deliberate, more cognizant of tradeoffs. You learn to watch for patterns that limit growth and patterns that enable growth. For example, over its lifetime the Clojure community has shifted from accepting argument lists and named parameters in their functions to accepting a single hashmap. This is because the single hashmap is easier to grow over time.

These principles are already well-known in the developer community. How often do you rename a URL path in your API? Never! How often do you rename a key in your API? Never! Do you ever decide to just return less data? No! Any time you're tempted to do any of these things, what do you do? You create a new name (usually v2).

Why do we do this? Because we know the pain it causes our customers, and we want to help them avoid that pain. Yet, somehow the rules change drastically when dealing with other developers. Suddenly it becomes okay to induce pain.

Note that this is not a static vs dynamic issue per se. Any library in any language can easily adopt these principles. However, I often see static type enthusiasts loudly proclaiming the benefits of type checkers by saying, "When I upgrade a library, I know that it's going to work." Fair enough. At the very least you know that your code will compile, and you won't be left wondering what changed and where. What's left unsaid, however, is the work that goes in to making the upgrade work.

Look again at the Scala code retention chart. How many of the cliffs in that chart represent a lot of work for their users? How much work could have been avoided if, instead, we opted to just not break things?


1: OneHappyFellow is a great follow btw.

2: The only time I've seen it done was when I did it myself for... reasons. If you do something like that to yourself, you can hardly blame a library for subsequent errors.

3: It does this in an efficient way by sharing elements of old structure with the new structure.

4: Coincidentally this is exactly how I ended up resolving Martin's issue in fusebox. I added an optional `::retry/exception` key to the function signature.


Thank you to OneHappyFellow, Slim Jimmy, and Matthew Boston for their input on this article.

A special thank you to Eugene Pakhomov for giving input on the article and for supplying the stack charts for the Clojure libraries.

Read Entire Article