The Rest of the Story: February Edition - JVM Weekly vol. 164

This time we have plenty of material: from a fascinating interview with Kotlin’s creator, through a groundbreaking change in Minecraft, to a whole wave of new projects proving that Java UI is in much better shape than anyone would expect. Let’s dive in!

Thanks for reading JVM Weekly! Subscribe for free to receive new posts and support my work.

1. February: The Rest of the Story

Article content

Gergely Orosz on his Pragmatic Engineer podcast conducted an extensive interview with Andrey Breslav, the creator of Kotlin and founder of CodeSpeak. The conversation is a goldmine of little-known facts about Kotlin’s history - for example, that the first version of Kotlin wasn’t a compiler but an IDE plugin, that the initial team consisted mainly of fresh graduates, or that smart casts were inspired by the obscure language Gosu.

But the most interesting thread concerns the future. Breslav is now building CodeSpeak - a new programming language designed to reduce boilerplate by replacing trivial code with concise natural language descriptions. The motivation? Keeping humans in control of the software development cycle in the age of LLM agents. As he put it - in the future, engineers will still be building complex systems, and it’s worth remembering that, even if Twitter is trying to convince us otherwise (heh).


Moderne announced that their OpenRewrite platform - known primarily in the Java ecosystem as a tool for automatic code refactoring - officially now supports Python. Python code can now be parsed, analyzed, and transformed alongside Java and JavaScript within the same Lossless Semantic Tree (LST).

Article content

Why does this matter for us JVM folks? Because modern systems rarely evolve in isolation. A Java service might expose an API consumed by a Python integration, a shared dependency might appear in backend services, frontend tooling, and automation scripts. With Python in the LST, dependency upgrades can be coordinated in a single campaign across multiple languages.

An interesting extension to the mission of one of the JVM ecosystem’s most important tools.


Article content

Igor Souza wrote a fun article celebrating the 40th anniversary of Legend of Zelda, comparing the triangle of performance improvements in Java 25 to the legendary Triforce. The analogy connects three key improvements: optimized class caching (AOT caching) for faster startup, Compact Object Headers for more efficient memory use, and garbage collection improvements. Java “Power, Wisdom, Courage” - genuinely a lovely connection with gaming lore.

Article content
Maybe not my favourite series, but I had a lot of fun with some of the editon. Happy Birthday, Link!

Continuing the video games related topics, big news from the gamedev side of JVM - Mojang announced that Minecraft Java Edition is switching from OpenGL to Vulkan as part of the upcoming “Vibrant Visuals” update. This is a massive change for one of the most popular games written in Java. The goal is both visual improvements and better performance. Mojang confirmed that the game will still support macOS and Linux (on macOS through a translation layer, since Apple doesn’t natively support Vulkan).

Interestingly, this is probably the first Java game to use Vulkan. Modders should start preparing for the migration - moving away from OpenGL will require more effort than a typical update. Snapshots with Vulkan alongside OpenGL are expected “sometime in summer,” with the ability to switch between them until things stabilize.

Article content

PS: I started to introduce my daughter to Minecraft ❤️ Fun time.


Article content

JetBrains announced that starting from version 2026.1 EAP, IntelliJ-based IDEs will run natively on Wayland by default. This is a significant step, particularly since Wayland has become the default display server in most modern Linux distributions.

One small difference in practice - the startup splash screen won’t be displayed, because it can’t be reliably centered on Wayland.

Positioning things is one of the hardest things in IT.

Article content

Bruno Borges published a practical guide on configuring JDK 25 for GitHub Copilot Coding Agent - the ephemeral GitHub Actions environment where the agent builds code and runs tests. By default, the agent uses the pre-installed Java version on the runner, which can lead to failed builds if the project requires newer features. The solution? A dedicated copilot-setup-steps workflow with actions/setup-java.

Short, concrete, and useful if you’re starting to experiment with coding agents in Java projects.


SkillsJars is a new project showcased on the Coffee + Software livestream (Josh Long, DaShaun Carter, James Ward ) - a registry and distribution platform for Agent Skills via... Maven Central.

The idea is simple and simultaneously crazy in the best possible way: Agent Skills (the SKILL.md format introduced by Anthropic for Claude Code, also adopted by OpenAI Codex) are modular packages of instructions, scripts, and resources that extend AI agent capabilities - e.g., a skill for creating .docx documents, debugging via JDB, or building MCP servers. SkillsJars packages these skills as JAR artifacts and publishes them on Maven Central under the com.skillsjars group, with full support for Maven, Gradle, and sbt.

Article content

The catalog already includes a Spring Boot 4.x skill (best practices, project structure, configuration), an agentic JDB debugger by Bruno Borges, a browser automation skill (browser-use), and official Anthropic skills for creating presentations, PDFs, frontend design, and building MCP servers. Anyone can publish their own skill - just point it to a GitHub repo.

Maven Central as a package manager for agentic AI - we truly live in interesting times.


Johannes Bechberger from the SapMachine team at SAP created a fun quiz where you have to guess the minimum Java version required from a code snippet. Over 30 years, Java added generics, lambdas, pattern matching, records... and it turns out most of us can’t precisely recall which feature arrived in which version.

A perfect way to kill five minutes over coffee (or an hour, if you’re as nerdy as I am).

Article content

If you think it’s too easy... I dare you to try Java Alpha version with alpha features 😉


Akamas published an analysis of the state of Java on Kubernetes based on thousands of JVMs in production.

The findings? Despite it being 2026, most Java workloads on K8s run with default settings that actively hurt performance. 60% of JVMs have no Garbage Collector configured, most heap settings are at defaults, and a significant portion of pods run with less than 1 CPU or less than 1 GiB RAM - which is a serious bottleneck for Java’s multi-threaded architecture. An old problem, but the data is striking.

PS: Ergonomic Profiles was always a great Idea IMHO.


JetBrains released an extension for Visual Studio Code that enables converting Java files to Kotlin. The converter (J2K) uses the same engine as IntelliJ IDEA. Just open a .java file, right-click, and select “Convert to Kotlin.”

An interesting move - JetBrains clearly wants the Kotlin ecosystem to expand beyond their own IDE.


Robin Tegg, whose piece on Java UI landed in the newsletter two weeks ago, created an awesome page - a comprehensive guide to Java UI frameworks, from desktop (JavaFX, Swing, Compose Desktop) through web (Vaadin, HTMX, Thymeleaf) to terminal (TamboUI, JLine, Lanterna). The motivation? Frustration with outdated articles referencing dead libraries. The result is the best single source of knowledge on the current state of Java UI in 2026.

Article content

If anyone tells you “you can’t do UI in Java” - send them this link.


Scala Survey 2026 - VirtusLab and the Scala Center have launched their annual community survey, and if you’re using Scala in any capacity, your 5 minutes can directly shape the language’s roadmap, library ecosystem, and tooling priorities. The survey evaluates Scala adoption patterns, pain points, and what the community actually needs - and the results have historically influenced real decisions about where development effort goes.

Take the survey here. Whether you’re a daily Scala developer or someone who occasionally dips into the ecosystem, your perspective matters. That’s unique possibility to shape the language

Article content

To wrap this section - 100 most-watched presentations from Java conferences in 2025 is a solid list to catch up on. And the article on 10 modern Java features that let you write 50% less code is a good refresher, especially for those who are mentally stuck on Java 8.

2. Release Radar

Article content

Quarkus 3.31

Quarkus 3.31 is a major release that arrived after a two-month gap since the last feature release. The headline addition is full Java 25 support, but the changelog is genuinely impressive. New Panache Next - the next generation of the Panache layer with improved developer experience for Hibernate ORM and Hibernate Reactive. Upgrade to Hibernate ORM 7.2 and Reactive 3.2. Support for Hibernate Spatial. Upgrade to Testcontainers 2 and JUnit 6 (yes, JUnit 6!). New quarkus Maven packaging with a dedicated lifecycle. On top of that: security annotations on Jakarta Data repositories, OIDC token encryption, OAuth 2.0 Pushed Authorization Requests, headless AWT on Windows for native images, and much more. Requires Maven 3.9.0+.

Release Notes

Eclipse GlassFish 8.0

GlassFish 8, released February 5th, is the first production-ready implementation of the full Jakarta EE 11 platform. The release, led by the OmniFish team, brings support for virtual threads in HTTP thread pools and managed executors, an implementation of Jakarta Data (repositories for working with both JPA entities and Jakarta NoSQL entities), and a new version of Jakarta Security with more flexible authentication options. Integration with MicroProfile 7.1 (Config, JWT, REST Client, Health). Requires JDK 21 as a minimum, supports JDK 25. Led by Arjan Tijms, Ondro Mihályi, and David Matějček, GlassFish is returning as a serious production option.

Release Notes

Open Liberty 26.0.0.1

Open Liberty 26.0.0.1 is the first release of IBM’s application server in the new year - a transition from the 25.x branch to 26.x. The main addition is log throttling - a mechanism for automatically suppressing repeated log messages that simply didn’t exist in Liberty before. Throttling is enabled by default: Liberty tracks each messageID using a sliding window and after 1000 repetitions within five minutes begins suppressing messages, logging an appropriate warning. Throttling can be configured at the messageID or full message level (throttleType), and limits can be changed (throttleMaxMessagesPerWindow) - or disabled entirely by setting the limit to 0.

Security fixes include a patch for XSS (CVE-2025-12635, CVSS 5.4), issues with wlp password key decoding, and a NullPointerException in SocketRWChannelSelector. Two new guides on observability with OpenTelemetry and Grafana were also added.

Worth noting: the beta is developing a Model Context Protocol Server feature (mcpServer-1.0) - allowing Liberty application business logic to be exposed as tools for AI agents. Beta 26.0.0.2 has already added role-based authorization and async support. MCP in a Jakarta EE application server - now that’s an interesting combination.

Release Notes

BoxLang 1.9.0

BoxLang 1.9.0 announced as “production-ready” - a release focused on stability, with over 50 bug fixes, improved datasource lifecycle management (eliminating connection leaks), and better context management (eliminating memory leaks).

But what exactly is BoxLang? It’s a modern, dynamically typed JVM language from Ortus Solutions — a company well known in the ColdFusion world for the ColdBox framework and CommandBox tooling. BoxLang is essentially an answer to the question “what if we took the best ideas from CFML, Python, Kotlin, and Clojure, and built it from scratch on a modern JVM?” The language is 100% interoperable with Java, uses invokedynamic for performance, and its runtime weighs just ~6 MB. Interestingly, BoxLang has a dual parser — it can natively run existing ColdFusion/CFML code without modification, which is critical for migrations from Adobe ColdFusion or Lucee (and is also Ortus’s primary business model).

The project’s ambitions are broad — BoxLang targets multi-runtime deployment: from classic web servers, through AWS Lambda, to iOS, Android, and WebAssembly. The project is open source (Apache 2.0), with commercial BoxLang+ and BoxLang++ plans for enterprise support. Ortus also announced a cloud-native version at $5/month — a clear signal they’re trying to move beyond the ColdFusion niche and compete more broadly in the dynamic JVM language space.

Release Notes

Apache NetBeans 29

Apache NetBeans 29 - released February 23rd, so completely fresh. Under the hood there’s plenty of substance: Gradle Tooling API upgraded to 9.3.0, bundled Maven updated to 3.9.12, Ant bumped to 1.10.15. On the Java side — performance fixes for Find Usages and refactoring, better support for import module (keyword highlighting), fixed form designer.

Notably, NetBeans is already being tested on JDK 26-ea and sets --sun-misc-unsafe-memory-access=warn (JEP 498) — clearly laying the groundwork for upcoming changes to sun.misc.Unsafe access. The IDE supports running on JDK 25, 21, or 17, with preliminary support for JDK 26. Refactoring problem propagation to LSP clients has also been improved — NetBeans continues developing its role as a Language Server backend, not just a classic IDE.

Release Notes

Ktor 3.4.0

Ktor 3.4.0 - a new version of the Kotlin framework for building asynchronous HTTP servers and clients. A stability-focused release, but with several noteworthy additions. Most important: OpenAPI documentation generation from code — a new compiler plugin combined with the describe API lets you build a Swagger model dynamically, directly from the routing tree. No more manually maintaining static OpenAPI files that drift out of sync with the code.

Also added: Zstd compression support (Facebook’s algorithm offering an excellent compression-to-speed ratio) in the new ktor-server-compression-zstd module, duplex streaming for the OkHttp engine (simultaneous sending and receiving over HTTP/2), and a new HttpRequestLifecycle plugin - allowing in-flight requests to be automatically cancelled when a client disconnects. The last one is a nod to structured concurrency: a client disconnection cascades to cancel the coroutine handling the request along with all launch/async children, making resource management for long-running operations considerably cleaner. Currently works with Netty and CIO engines.

Release Notes


3. Github All-Stars

Article content

Krema - Tauri, but in Java

Krema is probably the most interesting new project in this edition. For those who know Tauri from the Rust ecosystem - Krema is its Java equivalent. Lightweight, native desktop applications using the system webview instead of a bundled Chromium. Backend in Java, frontend in React/Vue/Angular/Svelte, communication through a type-safe IPC bridge.

Article content

Key features: system webview (WebKit on macOS, WebView2 on Windows, WebKitGTK on Linux); native communication via Project Panama (Foreign Function & Memory API from Java 25) - no JNI, annotate Java methods with @KremaCommand and call them from the frontend with full type safety, plugin system (SQLite, WebSocket, file upload, window positioning, autostart), native packaging with GraalVM or as a JAR.

The project looks well thought-out, and if it develops, it could be a game-changer for desktop apps in Java.

TamboUI - Terminal UI for Java, finally taken seriously

TamboUI announced by Cédric Champeau (known from Gradle and Micronaut) and Max Rydahl Andersen (JBang, Red Hat ) - a modern Terminal UI framework for Java. It was born from the observation that Rust has Ratatui, Go has Bubbletea, Python has Textual - and Java? System.out.println and prayers.

Article content

TamboUI offers a multi-level API: from low-level widget primitives (like Ratatui), through a managed TUI with event handling, up to a declarative Toolkit DSL that handles the event loop and rendering thread for you. Immediate-mode rendering, constraint-based layout, CSS support, PicoCLI integration, multiple backends (JLine, Aesh, Panama).

And most importantly - full GraalVM native image compatibility, making Java a serious player in terminal tooling with low memory usage and fast startup. Core works on Java 8+, but it’s most enjoyable with modern Java idioms.

JADEx - Null Safety for Java Without Changing the Language

JADEx (Java Annotation-Driven Extensions) is an approach to null safety in Java through annotations and compile-time processing - without waiting for Valhalla or a language spec change. The discussion on r/java shows the topic is still very much alive, with demand for pragmatic solutions.

JOpus - Wrapper for the Opus Codec

JOpus is a high-performance Java wrapper for the Opus audio codec. Opus (not the one you might be thinking of) is an open audio format that excels at VoIP, streaming, and gaming - and is now easily accessible from the JVM.

Article content

ChartX - OpenGL Charting Library

Article content

ChartX is a library for creating hardware-accelerated charts using OpenGL. An interesting technology choice given that Minecraft is moving to Vulkan - but if you need performant, hardware-accelerated visualizations in Java, worth a look.

JBundle - Packaging JVM Applications

JBundle is a tool for packaging JVM applications into distribution-ready formats. In the era of jpackage and GraalVM native image, JBundle offers yet another option in the deployment tooling ecosystem.


As a wrap up, I have the invitation 😁

Article content

PS: The full KotlinConf’26 schedule is live - it’s officially time to start planning your conference experience! This year’s program highlights the ideas, tools, and real-world practices shaping the future of Kotlin. Over two days, you’ll hear from the Kotlin team and community experts as they share insights, experiments, and lessons learned from building at scale.

Amon speakers, you will find:

The full list you can find there.

Thanks for reading JVM Weekly! Subscribe for free to receive new posts and support my work.

Permalink

The YAML Trap: Escaping Greenspun’s Tenth Rule with BigConfig

Greenspun’s Tenth Rule is a famous (and delightfully cynical) adage in computer science. While it was born in the era of C and Fortran, it has never been more relevant than it is today in the world of Platform Engineering.

If you’ve ever felt like your CI/CD pipeline is held together by duct tape, YAML-indentation prayers, and sheer willpower, you’ve lived this rule.

What is Greenspun’s Tenth Rule?

In the early 90s, Philip Greenspun stated:

“Any sufficiently complicated C or Fortran program contains an ad-hoc, informally-specified, bug-ridden, slow implementation of half of Common Lisp.”

The core insight is that once a system reaches a certain level of complexity, it inevitably requires high-level abstraction, automation, and dynamic logic. Instead of starting with a powerful, established language (like Lisp) built for those tasks, developers often “accidentally” reinvent a mediocre version of one using brittle configuration files and makeshift scripts.

The Rule in the DevOps Ecosystem

In DevOps, we strive for Infrastructure as Code (IaC). However, because we started with static configuration formats (YAML/JSON) and tried to force them to perform complex logic, we’ve essentially proven Greenspun right.

1. The YAML “Programming” Trap

Tools like Terraform, Ansible, Helm, and GitHub Actions began as simple configuration formats. But as users demanded loops, conditionals, and variables, these tools evolved into “accidental” languages.

  • The Problem: You end up writing complex business logic inside strings within a YAML file.
  • The Reality: You are using a “bug-ridden implementation” of a real programming language, but without the benefit of a debugger, a compiler, or proper unit testing.

2. Kubernetes as a Distributed Lisp

Some architects argue that Kubernetes is the ultimate manifestation of this rule. Its control loop the constant cycle of reconciling desired state vs. actual state mimics the recursive nature of Lisp environments. It is, in essence, a programmable platform designed to manage other programs.

Escaping the Trap: Putting Lisp Back in Ops

The industry has invested massive human capital into building Ansible roles, Helm charts, and Terraform modules. We shouldn’t throw them away, but we must stop trying to make them do things they weren’t designed for.

How do we escape Greenspun’s trap without rebuilding everything from scratch? By assimilating these tools (to borrow a 90s Star Trek reference).

This is the core design principle of BigConfig. Instead of fighting against limited YAML DSLs, BigConfig uses Clojure a modern, production-grade Lisp to wrap and orchestrate existing tools.

The BigConfig Philosophy: Express infrastructure logic with the most powerful dynamic language available, while still leveraging the ecosystem you already have.

Why it Matters: The Power of Assimilation

The Tenth Rule is a warning: Don’t reinvent the wheel poorly. If your infrastructure requires complex logic, stop forcing it into a flat config file.

From Static Files to Fractal Architecture

While a standard Helm package is limited strictly to Kubernetes, a BigConfig package is a Clojure function. Because BigConfig assimilates Ansible and Terraform alongside Helm, it isn’t siloed.

  • Truly Cloud Native: A Kubernetes application that requires specific cloud resources (like an S3 bucket or an RDS instance) can be abstracted into a single, cohesive unit.
  • First-Class Functions: In BigConfig, everything is a function. This leads to a fractal architecture where every layer from a single container to a multi-region cloud deployment is governed by the same recursive logic: Observe, Diff, and Act.

Ready to stop writing logic in YAML?

Operations is a hard problem. YAML is too rigid, and Go is too low-level for rapid infrastructure iteration. While Python and JavaScript are popular, they lack the REPL-driven development flow that makes infrastructure-as-code feel truly interactive.

Clojure is the most robust Lisp available today and it won’t let you down.

Conclusion

Greenspun’s Tenth Rule isn’t just a witty observation; it’s a technical debt warning. When we try to solve 21st-century infrastructure challenges using static configuration files, we inevitably end up building “shadow” programming languages that are difficult to test, impossible to debug, and fragile to scale.

By embracing a functional, Lisp-based approach through BigConfig, we stop fighting the limitations of YAML and start leveraging the power of actual logic. Instead of building a “bug-ridden implementation of half of Common Lisp,” we use the real thing Clojure to orchestrate, automate, and scale.

The goal of Platform Engineering shouldn’t be to write more scripts; it should be to create elegant, recursive systems that can manage themselves. It’s time to move past the duct tape and prayers and give our infrastructure the robust, dynamic foundation it deserves.

Would you like to have a follow-up on this topic? What are your thoughts? I’d love to hear your experiences.

Permalink

Managing Complexity with Mycelium

Software architecture is at root a creature of human frailty. The sacred cow of clean code and the holy grail of design patterns are understood to be, at least in practical terms, little more than tricks to help people keep their sanity along the way. Human cognitive capacity is strictly limited, and we're still figuring out ways to reliably build machines significantly more complex than can be held in a single mind. Current attempts to offload coding tasks to language models are hitting the same wall. These models can be brilliant, but only up to a point. They’ll effortlessly compose a flawless function, but when challenged to manage a project with a thousand such moving parts, they quickly lose the plot. This problem is commonly known as context rot, but it might equally well be called a coding architecture failure.

If one hands an LLM a big pile of mutable state and loosely defined relationships, a solution that doesn’t actually work will be hallucinated inevitably, and when it does work, it will do so largely by accident. This comes about because there are just too many moving parts, and the ground truth cannot be kept track of. Humans are known to have exactly the same problem. Enormous amounts of time are spent chasing down bugs that exist because some distant part of the app decided to tweak a variable it didn’t own. Shared mutable state quickly leads to an overwhelming information flow, generating invisible threads between components that make it nearly impossible to know the scope of the change being made. Seemingly innocuous code changes end up mutating state in unintended ways that lead to unexpected consequences.

We solve difficult problems by dividing them into smaller, more manageable ones, and then composing them together. Successful architecture requires separable components, each with a function that is easy to grasp, and creating interfaces between them to abstract over their internal details and present to the outside world only their functional aspects. We must discover the boundaries between components, separate external effects from internal implementation details, and arrange for each component to control its own context. Then, and only then, can we be sure that we will always be working in a clear context that we can fit in our heads.

We naturally long for layers of organization. Hierarchies permit us to construct separate, self-contained units which can then be connected together to make larger structures. It's a powerful kind of architecture, one that facilitates writing large projects by abstracting over the complexity of the constituent parts. In working on a given component, we have to know all its details. But in using it, we only need to know what it does, which is entirely reflected in its API surface. The internal complexity is encapsulated within the API boundary. These are building blocks that give us a stable base upon which to build higher-level abstractions. Several such components can be assembled into a bigger block, where the connections between subcomponents become its internal complexity. The composite component becomes a new layer, providing its own API, which can be used by still higher-level abstractions. If what I'm describing sounds familiar, it is because that's exactly the way in which software libraries work. A library is simply a model of a class of problems, and when we encounter these types of problems, we can use it as an off-the-shelf building block.

A complex system has to be resilient and adaptable. But if every component is hooked directly into every other, there’s no way you can anticipate what a change in any one place will do. There are no boundaries to stop the chaos. Anything that works at scale relies on stable subassemblies. Herbert Simon described it long ago in a parable of two watchmakers. The first built his watches one piece at a time. He had to keep the entire complexity of the watch in his mind as he did his work, for any interruption would undo his progress and send the component pieces clattering to the floor. He worked on a flat plane of complexity, having to think through the workings of the entire watch just to insert a single gear. The other watchmaker did well because he first constructed small, stable modules which he was able to click together. So if he was interrupted, only a small amount of work was lost. He never had to think through more than a few components at a time. The system was resilient because it was built as a hierarchy of subunits, each able to stand on its own merit, and so the watchmaker had to consider a small context rather than the stupefying complexity of the whole.

Practically any large system can so be divided into smaller, nested subsystems, which communicate with one another as they go about their business. The intimate workings of other subsystems need hardly be known, which permits these modules to form. Individual components are not encumbered by what’s going on at other places where events are transpiring at different paces and according to a different set of circumstances. This is how hierarchies help in managing the complexity of a system. Each subsystem can evolve on its own. A malfunction in one region can be quarantined; it need not bring down the entire enterprise. A useful way to think of hierarchies is to treat them as connective tissue between the various subsystems of the program, as a principal means of control, providing the architecture and the infrastructure that coordinates the internal functioning of the individual parts of the system.

In contrast, in software development, practitioners are often confronted by the opposite scenario. Large software projects degenerate into a tangled web in which every function depends on a global state or a shared database connection. Humans have difficulty working with such systems because they can’t reason about their pieces independently. If a feature requires more information than can be kept in one’s head at once, guesses and assumptions begin to proliferate.

Just as humans need clear boundaries to maintain sanity, coding agents are even more vulnerable to cognitive saturation. There’s no intuition in a large language model that can tune out the noise. Everything is placed into its context window. If a piece of code is a labyrinth of obscure dependencies, the agent has to parse through it all to make a single modification. Eventually, the context becomes so cluttered that the purpose of the task gets lost in the entropy. The model starts hallucinating because of its inability to distinguish between the logic it’s supposed to refactor and the three hundred other things it’s presented with.

Where We Are At

To understand how we might solve this for both humans and machines, let's first examine the tools we've already developed for managing complexity. The software industry has spent decades working out how to decompose code into manageable pieces. A very powerful set of tools for this purpose is already available. At the inter-process scale, there are microservices, which provide a physical boundary between programs. And at the intra-program level, there are techniques such as message passing, pure functions, and immutable data.

The functional style is particularly good at taming global state. Here, functions are the smallest building blocks, and pure functions can be considered on their own. The idea here is to build systems from separate, single-purpose parts, by using isolation, composition, and clear contracts inside the application's own logic. Code becomes a pipeline focused on the data flow. A program takes one piece of data as input, and produces another as output, pumping it through a sequence of pure functions. Since data is immutable, it is transparent and inert, having no hidden states or secret side effects. This is how stable contracts are formed. Once the transformations have been specified, the interface is a contract you can trust.

When you design applications in this way, the overall architecture looks like a network of railway lines, with the input data package needing to get from point A to point B. The package might pass through many different stations on the way to its destination, each one inspecting the package to decide where to route it next. An HTTP handler takes the payload, parses the request, determines the content type, and forwards it on toward an authentication handler. The authentication handler might inspect permissions in payload metadata to determine where to send it next, and so on. Eventually, the package arrives at its intended destination where the data gets serialized, stored, or presented to the user.

But even with all these great functional tools, we still tend to tangle two rather different kinds of code together. We mix code that cares what the data means and the code that cares how it travels from one component to another. Traditional software design structures embed the routing implicitly in the function call graph. For example, our authentication handler will have the logic to select the next function to invoke within its implementation. The control logic and its internal implementation details, thus, end up being intertwined in an ad hoc manner, resulting in a significant coupling problem. If you wish to alter the flow of your application, you must sift through voluminous amounts of incidental code describing the internal minutiae of component implementations.

An effective solution to this problem is to use inversion of control by removing routing logic from the functions and elevating it to first-class citizenship in the design. Why is a state machine the natural fit for this, rather than an event bus or dependency injection? Event buses scatter routing logic to the winds with components shouting into the void, making the overall flow impossible to trace. Implicit callbacks hide the flow inside the implementation. State machines, on the other hand, make routing declarative and visible in one place. They force the separation of what to do from how to do it.

Introducing Mycelium

I’ve spent a great deal of time considering how to construct a system where distinct components are clearly separated by design, with clear boundaries between semantics of the code and the implementation details. This is the basic conceptual orientation of Mycelium, which treats the program as a recursive ecosystem of workflows, solving the very routing problem described above.

Clojure provides the tools for writing pure functions, but falls short of giving us guidance on how to orchestrate them when writing large applications. I initially developed Maestro to provide a clear organizational framework, separating side-effectful concerns from pure calculation, and structuring workflows as graphs where the nodes represent computations of state, and the edges represent transitions between them. The nodes are distinct, context-free blocks, responsible for specific tasks, linked by a thin coordinating layer controlling the flow of data between them. The state itself is represented as a map that's passed from one node to another.

The business logic for each node lives inside a Mycelium component called a cell. Since cells are completely unaware of one another, they are inherently isolated. Each one can be viewed as a miniature self-contained application. It knows how to do its specific job and adheres to a strict lifecycle. It takes a state map, loads the data, runs the logic, and computes a new state as its output. All they can access are the IO resources, and a map containing the input state.

Maestro is responsible for arranging these transitions, so that the decisions are pure, and their effects are encapsulated. When a component needs to move itself from one state to another, it does not simply reach in and take what it needs. Instead, it updates the state map, and delegates to Maestro to orchestrate the transition. Again, this keeps the code responsible for deciding what will happen next separate and distinct from the private parts of the individual cells.

Each cell is additionally wrapped in a Malli schema, which gives the cell a protocol to abide by. You can’t simply hope that the LLM will understand your intentions when they’re expressed in plain English. What you need is a formal contract to determine whether the implementation is correct. Malli enables us to specify precisely what a cell is entitled to receive and what it can produce as its output. It's a flexible way to encode deep, structural invariants representing the interface of the cell.

An agent tasked with constructing a handler for a particular node operates within the constraints of a contract, enforced by the schema, both during development and at runtime. Crucially, the agent doesn't need to scour the codebase to discover the relevant cell; the orchestration layer (acting as a Conductor) assigns the specific cell ID and its schema directly to the agent. It operates within a tightly bounded context provided to it. If the code produced by the agent does not adhere to the contract, if the output is even slightly off, the system will reject it providing meaningful feedback on what went wrong.

Think, for a minute, about what this does for the scope creep problem. The schema defines the boundary of the cell letting the agent know exactly what keys are in the map, what the data types are, and what the constraints are. Since the components do not interact directly, the agent has a well-defined, perfectly sized context that it needs to understand.

We now have a self-correcting loop. The primary agent, the Conductor, designs the workflow in EDN. A fleet of smaller, specialized agents do the individual tasks. If one of them makes an error, it is detected by the Malli contract before it can propagate forward to the next node in the graph. The specific validation failure is known at the point where it arose, and its scope is limited to the node that produced it.

The State Machine Graph as a Contract

Treating an application's high-level behavior as a state machine graph provides us with a master blueprint. It allows us to determine what the intent of a particular workflow is by reading a declarative schema describing the states and the transitions between the cells. A human can review and approve a data flow diagram, which specifies the semantics of each cell, and the rules guiding the flow of data across them. The details of how each cell functions are abstracted behind its API, and managed by the agent responsible for implementing its functionality. Hence, the orchestrator only needs to concern itself with the routing aspect of the application and ensuring that the schemas of nodes sharing an edge are compatible.

The orchestration layer is in charge of directing the work, and executing the branching logic. Its sole concern is to examine the results from each node to decide on the next branch to take according to the EDN specification.

Because the intercellular connections form a directed graph, and since motion is governed by payload state, with the routing logic separated from the cell code, you can, in principle, determine the entire decision tree of the application just by examining the EDN spec. Instead of having to dig through conditional branches buried in thousands of lines of implementation code, you have a declarative map of possibilities.

How This Works in Practice

You can glimpse the way in which Mycelium binds these ideas together by examining a snippet from the user-onboarding demo. The workflow definition is the point of departure. We start by defining a cell and its strict contract:

;; A cell contract for session validation
{:id       :auth/validate-session
   :doc      "Check credentials against the session store"
   :schema   {:input  [:map
                        [:user-id :string]
                        [:auth-token :string]]
              :output {:authorized   [:map
                                       [:session-valid :boolean]
                                       [:user-id :string]]
                       :unauthorized [:map
                                       [:session-valid :boolean]
                                       [:error-type :keyword]
                                       [:error-message :string]]}}
   :requires [:db]}

This map represents a stable building block. There is no ambiguity in a declarative specification. A cell is defined by the shape of its input and output, along with its resource requirements. The routing logic is extracted entirely into separate :edges and :dispatches keys in the workflow.

 :edges
 {:validate-session {:authorized   :fetch-profile
                     :unauthorized :error}}

 :dispatches
 {:validate-session [[:authorized   (fn [data] (:session-valid data))]
                     [:unauthorized (fn [data] (not (:session-valid data)))]]}

The edges shown in the snippet represent the possible transitions. The dispatches constitute a list of node identifiers, each with an associated decision function that examines the state and determines whether it should be processed by the identifier in question. Dispatches are processed on a first come, first served basis; that is, the state will be routed to the first matching identifier found.

Next, we have the cell associated with the spec, which is responsible for performing the actual work. Following Integrant philosophy, the cells are defined as a collection of multimethods.

(defmethod cell/cell-spec :user/fetch-profile [_]
  {:id      :user/fetch-profile
   :doc     "Fetch user profile from database"
   :handler (fn [{:keys [db]} data]
              (if-let [user (db/get-user db (:user-id data))]                
                (assoc data :profile (select-keys user [:name :email]))
                (assoc data
                       :error-type    :not-found
                       :error-message (str "User not found: " (:user-id data)))))})

Note how the :user/fetch-profile handler doesn’t need to know where the data came from, nor does it decide where to send it. All it receives is the current state. The cell does its work and then returns an updated map. The orchestration layer evaluates the dispatches against this new data to select the next edge.

Long before a user ever makes a request, the workflow goes through a rigorous validation phase at compile time. During startup, the engine verifies that every cell exists in the registry, that every transition has a valid destination, and that all the input and output schemas chain together with no discontinuities. This is the moment where the blueprint becomes an active, executable process.

When a request (like POST /api/onboarding) actually arrives, the HTTP routing library recognizes the endpoint and shoves it onward to an onboarding handler. This handler summons the pre-compiled workflow engine, giving it the database connection and the raw request. As the state machine proceeds through its transitions, the Malli schemas are serving as sentinels.

Reliability, Debugging, and Testing

The State Map acts as the single source of truth throughout this lifecycle. Every transformation is explicit in the return value, and there are no side channels modifying the state. Like a messenger, it travels through the system, carrying on its person all the data that has been gathered up to this point, as well as associated metadata.

Because the state map keeps a :mycelium/trace of every transition, you get unparalleled observability. If a workflow fails, you don’t just get a stack trace telling you where in the codebase the crash happened; you get the full history at the moment of failure. You can see the inputs, the previous steps, and the exact data that caused the routing logic to stumble. For the coding agent, it’s as if there’s a black box flight recorder on every single run.

Such level of observability fundamentally transforms how we test applications. Testing in often seen as a thoroughly distasteful chore, so much so that you will often find people spending more time with mocks and dependency injection than actually writing tests.

Mycelium treats each fragment of logic as a pure update of a data structure. Testing reduces to a straightforward exercise in data juggling. You don’t have to mock up a database to test how a particular system handles a User Not Found scenario; you simply feed the component a state map lacking the :user key and see what output it produces.

Because every workflow node is contractually bound by its Malli schema, you can take the :validate-user-data handler, feed it a map of bad data, and check that it sets an :invalid key on the state map. You’re not testing the whole onboarding flow; you’re testing one specific cell.

In Mycelium, logical integration tests can be performed trivially, simply by executing the workflow with a mock resource map. Resources like the database are passed in separately, so a real Postgres connection can be exchanged for a mock in the test suite. The difference is entirely irrelevant to the workflow.

;; A logical integration test
(deftest onboarding-workflow-test
  (let [ds       (create-test-db!)
        compiled (wf/compile-workflow onboarding-manifest)
        result   (fsm/run compiled
                         {:db ds}
                         {:data {:http-request
                                 {:body {"email" "test@example.com"}}}})]
    (is (some? (:profile result)))
    (is (= "test@example.com" (get-in result [:profile :email])))))

Because of the trace history, your test assertions become incredibly descriptive. You aren't just checking if the final result is 200 OK. You are able to check that the system moved from :start to :validate-session to :fetch-profile in the exact order you expected. You get the confidence of a full system test by simply passing mock resources to your workflow, and verifying that transitions happen in correct order.

Layered Abstraction and Infinite Scale

The entire design is naturally recursive. A complete system implemented as a network of cells can itself be viewed as a single cell, which offers up an interface and can then be slotted into a yet-larger state machine network. You might have a simple network handling user login, which becomes a component in a medium-scale network managing the payment process, which itself becomes a component in a large-scale network implementing a complete online emporium. Scaling becomes a matter of arranging components on a graph rather than increased coupling within the codebase.

But of course, there must be clear boundaries set for such a system. The most promising way to define components and graphs draws on functional programming and formal methods. For example, Malli-driven schemas provide a means of establishing checkpoints that the LLM agent cannot bypass. The agent must fulfill the contract by adhering to all the constraints and requirements.

Once the high-level design is in place, these contracts can be issued to the agents in charge of the nodes in the graph. You no longer need an exceptionally capable agent that's able to comprehend a massive context and keep track of the interconnections across a sprawling application. The job description of the agent in charge of the flow of control is likewise dramatically circumscribed. The internal details of the cells can be ignored, with attention paid only to the graph itself. If the graph grows too complex, it, too, can be divided into separate, independent subgraphs. In this architecture, the context never needs to become unmanageably large.

The Agent Synergy

Historically, this kind of design has been hard to sell. Workflow engines and state-graph systems are not without their proponents, but they haven’t exactly swept the world. The basic problem is that they require a lot of additional ceremony. While wiring functions together by hand permits the programmer to forge ahead in a straight line, forcing yourself to step back, design a state graph, worry about the transition logic among disparate files, and code the glue just feels too onerous. Most programmers would much rather just bang out an if statement and keep going.

But the picture changes completely when we introduce coding agents. A large language model lacks ego and has no difficulty writing boilerplate code. It does not find itself bored by ceremony, nor frustrated by the need for upfront structural planning. In fact, language models thrive on it. What is a tedious tax for a human developer serves as an explicit, unambiguous map for an agent. By embracing this structure, the agent secures the very boundaries it needs to stay coherent.

Divorcing data flow from data transformation resolves the problem of LLM context overload in an elegant and general way. A strategic agent, in the form of the Conductor, coordinates the flow in the orchestration layer, plotting the tracks. Meanwhile, individual handler agents are responsible for writing, refining, and documenting their particular domain which is just a station on the railway. They do so within a safe, bounded context, avoiding the thicket of intractable problems posed by runaway cognitive saturation.

Imagine a future in which coding agents assemble software systems by integrating and adapting existing workflows that have been approved by human oversight. We no longer have to suffer the frustration of constructing complex machines built out of opaque and unreliable components. We can rely on standardized and tested building blocks, following a clear and verifiable assembly plan, so that the resulting system is guaranteed by its very design to be correct, adaptable, and observable.

Permalink

State of Clojure surveys

I checked the Previous Years links in the latest State of Clojure survey post and wrote down respondent counts by year.

Here are the numbers:

  • 2010: 487
  • 2011: 670
  • 2012: 1372
  • 2013: 1061
  • 2014: 1339
  • 2015: 2445
  • 2016: 2420
  • 2017: absent…
  • 2018: 2325
  • 2019: 2461
  • 2020: 2519
  • 2021: 2527
  • 2022: 2382
  • 2023: 1761
  • 2024: 1549
  • 2025: 1545

Or, as a graph:

State of Clojure survey respondents by year

What this suggests

There is a very obvious shape:

  1. Rapid growth in early years
  2. Long plateau
  3. Drop in the last 3 years

My take: these numbers are not a measure of absolute community size, but, within some error bars, they represent the trends of the community growth (or, recently, shrinkage). Looks like the trend is reversing to growth once again, though! That makes me hopeful 😊

Permalink

Introducing BigConfig Package

Helm has long been the gold standard for Kubernetes, but it hits a wall the moment you step outside the cluster. While there are projects to bridge this gap, they never quite achieved Helm’s level of ubiquity.

BigConfig Package is taking a different path. It aims to deliver both infrastructure and software as a unified, cohesive delivery.

The Case Against the “CLI Proliferation”

One of the most radical aspects of BigConfig Package is what it lacks: a dedicated CLI. In BigConfig’s philosophy, a CLI is just another tool that end-users should tailor to their specific needs. We argue that the current explosion of specialized CLIs is actually a symptom of unsolved underlying problems:

  • Bash Limitations: The fragility of shell scripting forces developers to wrap logic in binaries.
  • Interoperability Gaps: Integrating multiple CLI tools via scripting is often easier than managing complex library dependencies, leading to “scripting fatigue.”
  • The Go Paradox: While static languages like Go are excellent for single binary deployments, they force the creation of monolithic tools that make composition more difficult (e.g., Terraform plugin is based on GRPC).

BigConfig solves these issues by leveraging Clojure and Babashka. By using a dynamic, high-performance language, we replace rigid tools with flexible Babashka tasks, offering the power of a library with the convenience of a script.

The Three Environments: Shell, REPL, and Lib

Most DevOps professionals only interact with tools (like Helm) in the Shell environment. BigConfig expands this to three distinct planes:

  1. Shell: For quick, command-line execution.
  2. REPL: For real-time, interactive debugging and experimentation. No more “edit-coffee-fail” loops.
  3. Lib: For deep automation. You can orchestrate complex scenerio without reimplementing anything when switching gear from CLI tool to in-house solution.

What is a BigConfig Package?

There is no need to reinvent the wheel. A BigConfig Package is simply a Clojure repository, and the package itself is just a Clojure function written with BigConfig Workflow .

Our first application, Walter , tackles a challenge Helm simply wasn’t built for: Remote Development Environments on machines instead of containers.

While devcontainers are popular, containerizing a full development environment on Kubernetes has significant overhead and limitations. Nix is the superior choice here. Its native multi-user support makes it ideal for high-powered “beefy” machines shared by multiple developers.

In this setup, Terraform and Ansible do the heavy lifting but remain completely hidden once development is complete, leaving behind a clean interface that doesn’t “leak” its underlying complexity.

The use case of Remote Development Environment is getting traction and by integrating a Linux user to run a self-hosted GitHub runner, you unlock massive efficiency:

  • Cost Reduction: Run Dev and CI on the same hardware.
  • Instant Caching: CI pipelines never miss a cache hit because the environment is shared.
  • Absolute Parity: “Works on my machine” becomes a reality because the Dev and CI environments are identical.
  • Native Speed: Docker runs natively, eliminating the virtualization overhead found on macOS or Windows.

Comparison: Helm vs. Babashka

The Helm Way

In this paradigm, you manage repositories and install charts. It typically requires juggling YAML, Go templates, and Bash.

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm install my-redis bitnami/redis

The Babashka Way

With BigConfig, helm install becomes bb redis create. The CLI is defined by the package creator but can be customized by the user.

We use create instead of install in this case because the infrastructure is part of the delivery. Using bb instead of bash provides a unified environment where everything is written in Clojure, replacing the fragmented mix of Bash, Compiled Go, and YAML.

;; bb.edn
{:deps {io.github.amiorin/redis {:git/sha "eaff6f"}}
:tasks
{:requires ([redis :as redis])
redis {:doc "Provision a Redis instance"
:task (comp-wf/redis* *command-line-args*)}}}
bb redis create
bb redis delete

Conclusion

The era of managing a dozen different CLIs just to deploy a single stack is reaching its breaking point. BigConfig offers a way out, not by building a bigger tool, but by providing a more flexible foundation. By unifying the way we handle infrastructure and applications through Clojure, we aren’t just deploying software; we are creating a more cohesive, reproducible, and developer-friendly ecosystem.

Would you like to have a follow-up on this topic? What are your thoughts? I’d love to hear your experiences.

Permalink

Senior Software Developer (backend) at Crossref

Senior Software Developer (backend) at Crossref

90

  • Location: Remote and global, to partially overlap with working hours in European time zones.
  • Type: Full-Time, 40 hours a week, Mon-Fri.
  • Remuneration: 90k USD equivalent. We pay salaries in the currency of the country in which you’re based. We arrive at the local USD-equivalent salary by determining the average 5-year USD exchange rate, to stabilise currency fluctuations.
  • Benefits: Check out our Employee handbook for more details on paid time off, unlimited sick time, paid parental and medical leaves, and more.
  • Reports to: Program Technical Lead, Carlos del Ojo Elias
  • Timeline: Advertise in February-March and offer by April

About the role

We are looking for a Senior Software Developer to join our Contributing to the Research Nexus (CRN) program. In this backend-focused role, you will help maintain, extend, and modernize our existing services while also leading the design and implementation of new greenfield systems. The role centres on JVM technologies and cloud-native, distributed systems operating at scale.

Crossref collects a wide range of metadata for an ever-growing and increasingly diverse collection of scholarly outputs. We build and operate services that register, link, and distribute scholarly research metadata. The CRN program develops retrieval, matching, and enrichment services that integrate closely with systems across Crossref.

We are a small organisation with a big impact, and we’re seeking a mission-driven Senior Software Developer who can help maintain and evolve our services, design well-scoped solutions, and contribute to operational reliability through code reviews and documentation. This role will collaborate closely with colleagues across Technology and Programs & Services teams.

Key responsibilities

  • Understand Crossref’s mission and how we support it with our services
  • Work collaboratively in multi-functional project teams
  • Work closely with the Programs & Services Team to solve problems, maintain and improve our services and execute technology changes
  • Collaborate with external stakeholders when needed
  • Produce well-scoped and testable software design and specification
  • Implement and test solutions using Clojure, Kotlin, Java and other relevant technologies
  • Pursue continuous improvement across legacy and green-field codebases
  • Provide code reviews and guidance to other developers regarding development practices and help maintain and improve our development environment
  • Identify and report vulnerabilities and inefficiencies in our services
  • Document and share development plans and changes
  • Be an escalation point for technical support; investigate and respond to occasional but complex user issues

About you

You’re a software developer who enjoys understanding problems end-to-end and making thoughtful technical decisions. You’re comfortable working with ambiguity, you care deeply about users, and you take pride in building systems that last. You don’t need close supervision, but you value collaboration, challenge assumptions constructively, and know when to bring others into technical decisions.

We know no-one will meet all the requirements, but we are looking for people who are willing to learn and like to meet new challenges - please apply if this feels like you!

Essential skills and experience:

  • Minimum 5 years of hands-on experience in software development, engineering, or similar
  • Working knowledge of Clojure or another Lisp / functional language, or demonstrated ability and willingness to learn Clojure quickly.
  • Familiarity with JVM technologies (Kotlin and/or Java)
  • Comfortable working with Git, including code reviews and collaborative workflows
  • Experience contributing to or maintaining production systems, including reading and extending existing codebases
  • Experienced with continuous integration, testing and delivery frameworks, and cloud operations concepts and techniques
  • Familiar with Docker technologies
  • Strong communication skills and a collaborative approach to problem-solving
  • Strong written communication skills, particularly for design discussions and technical documentation
  • Comfortable being part of a geographically distributed team
  • Self-directed, a good manager of your own time, with the ability to focus

Nice-to-have:

  • Curious and tenacious at learning new things and getting to the bottom of problems
  • Strong understanding of functional programming concepts, including immutability, pure functions, higher-order functions, composition
  • Outstanding at interpersonal relations and relationship management
  • Ability to work autonomously while collaborating in a distributed team environment
  • A working understanding of XML and document-oriented systems such as Elasticsearch
  • Some experience with Python, JavaScript or similar scripting languages
  • Experience building tools for online scholarly communication or related fields such as library and information science
  • Comfortable working in open source projects, including public issue tracking, pull requests, and community discussion
  • Experience with JVM web frameworks (Spring, Quarkus, or similar)
  • Direct experience with Clojure in production, especially in open source projects
  • Experience with JVM internals, performance tuning, or memory management
  • Familiarity with the scholarly communications domain

About Crossref & the team

We’re a non-profit membership organisation that exists to make scholarly communications better. We rally the community; tag and share metadata; run an open infrastructure; play with technology; and make tools and services—all to help put research in context.

We envision a rich and reusable open network of relationships connecting research organisations, people, things, and actions; a scholarly record that the global community can build on forever, for the benefit of society. We are working towards this vision of a ‘Research Nexus’ by demonstrating the value of richer and connected open metadata, incentivising people to meet best practices, while making it easier to do so. “We” means 23,000+ members from 160+ countries, 170+ million records, and nearly 2 billion monthly metadata queries from thousands of tools across the research ecosystem. We want to be a sustainable source of complete, open, and global scholarly metadata and relationships.

Take a look at our strategic agenda to see the planned work that aims to achieve the vision. The sustainability area aims to make transparent all the processes and procedures we follow to run the operation long-term, including our financials and our ongoing commitment to the Principles of Open Scholarly Infrastructure (POSI). The governance area describes our board and its role in community oversight.

It also takes a strong team – because reliable infrastructure needs committed people who contribute to and realise the vision, and thrive doing it. We are a distributed group of 50+ dedicated people who take our work seriously, but don’t take ourselves seriously - we like to play quizzes, measure coffee intake, and create 100s of custom slack emojis. We do this through fair policies and working practices, a balanced approach to resourcing, and accountability to each other.

We can offer the successful candidate a challenging and fun environment to work in. Together we are dedicated to our global mission and we are constantly adapting to ensure we get there. Take a look at our organisation chart, the latest Annual Meeting recordings, and our financial information.

Thinking of applying?

We especially encourage applications from people with backgrounds historically under-represented in research and scholarly communications. You can be based anywhere in the world where we can employ staff, either directly or through an employer of record.

We will invite selected candidates to an initial call to discuss the role. Following that, shortlisted candidates will be invited to work on a short (1-2 hours) take-home assignment. This will be followed by a technical interview. The last step will be a panel interview, where you will receive questions in advance. All interviews will be held remotely on Zoom.

Click here to apply!

Applications close on March 10th, 2026.

Anticipated salary for this role is 90k USD-equivalent, paid in local currency. Crossref offers competitive compensation, benefits, flexible work arrangements, professional development opportunities, and a supportive work environment. Check out our Employee Handbook for more details on paid time off, unlimited sick time, paid parental and medical leaves, and more.

Equal opportunities commitment

Crossref is committed to a policy of non-discrimination and equal opportunity for all employees and qualified applicants for employment without regard to race, colour, religion, sex, pregnancy or a condition related to pregnancy, sexual orientation, gender identity or expression, national origin, ancestry, age, physical or mental disability, genetic information, veteran status, uniform service member status, or any other protected class under applicable law. Crossref will make reasonable accommodations for qualified individuals with known disabilities in accordance with applicable law.

Thanks for your interest in joining Crossref. We are excited to hear from you!

Permalink

One year of LLM usage with Clojure

Introduction

This essay is a reflection on using LLM agents with the Clojure programming language. At Shortcut, we have spent the last year building Korey, an LLM agent focused on product management. During that time, we have attempted to use different LLM agents to work with our rather large Clojure code base, ~250,000–300,000 lines of Clojure code. In doing so, we discovered a lot. I hope this essay can help people in the Clojure community and in other unorthodox languages more generally take more advantage of LLM tools for their work. I would break our approach down into eras:

  • Early adoption and struggles
  • The doldrums of Claude Code
  • The clojure-mcp revolution
  • Skills, Prompts, and OpenCode
  • System prompt refinements
  • PI Agent and liberation

Early Adoption and Struggles

It is commonly understood that LLM models are more effective with languages that dominate the training dataset. Research has shown that model performance varies significantly based on the volume of training data for each programming language, with Python, JavaScript, and TypeScript being heavily represented in public code repositories. This fact is and was concerning to me. For one, at Shortcut we have a large Clojure code base that we have grown, nurtured, and maintained over the last 11 years. We don't have the time, money, or interest in rewriting this to Python or TypeScript simply because state-of-the-art models prefer these languages. Additionally, to me, it does not seem like a good business move to throw out working code.

With that in mind, we decided to try to teach Claude Code how to write Clojure code well. Quickly, we realized that certain aspects of the way Claude Code is structured by default make it very difficult to work with a large code base like ours. An example of this is that Claude will run the entire test suite after each thing it implements, and running the entire test suite locally on our machine takes several minutes at this point, unfortunately. As Clojure engineers, we prefer tight feedback loops on the REPL. This several-minute-long test suite run was unacceptable to us.

We began to tweak our AGENT.md file, teaching Claude Code about how Clojure and Clojure's data structures work. With that, we were able to narrow the static verification steps that Claude Code made after each point in its implementation process. We noticed large improvements in our performance and our iterative process at this point.

The doldrums of Claude Code

At this point we felt using Claude Code was mostly functional, and we were capable of achieving a certain level of development flow with Claude Code. Claude used our large code base itself as a model of how it should write Clojure code, although we often had to prompt it to do so. I began experimenting with the best ways of prompting the LLM agent. One of the things I discovered is that specifying in detail what the LLM agent should do is critical; you can't leave any ambiguity.

However, we still struggled with certain aspects of how Claude approached software engineering. For example, we noticed that Claude often leapt ahead before looking. Claude would write a bunch of code — potentially several thousand lines over a few minutes — and then attempt to verify it. The problem is that because of hallucinations or misunderstandings, the new code would often contain errors. At that time, roughly six months ago, Claude was poor at debugging these errors.

Additionally, we observed a pattern: when Claude ran into an error, it often solved the problem by adding more complexity, which, as software engineers, we know is rarely the right approach. Consequently, we would see Claude spend a lot of time and tokens debugging a problem it created, and it couldn't resolve it because the code didn't actually belong in our code base. This leap-before-looking severely limited Claude's effectiveness in our code base.

We also noticed some more fundamental flaws with the way Claude—this was Sonnet 3.5 at the time—was approaching Clojure code. One thing we commonly notice is that Sonnet defaults to an imperative programming approach, which we know does not work well in Clojure. For example, we discovered, embedded in a large code change, a doseq with an atom where a map, or a reduce would be preferable. These issues were problematic for us because Claude can generate a lot of code, which is very difficult to undo. Ultimately, we want the LLM to generate the correct code in the first place. So we faced the dilemma: how do we achieve better functional Clojure code from the LLM? The next step was to explore certain avenues to achieve that.

The clojure-mcp revolution

The first step I took was to explore the Clojure MCP tool. This tool exposed a set of editing and evaluation features that greatly reduce AI hallucinations, and with that we were able to ground the LLM better in our code base. The Clojure MCP's edit functionality was essential for preventing invalid parentheses, syntax errors, and other issues from entering our code base.

Clojure MCP fundamentally altered my belief in the ability of LLMs to write effective Clojure code. Previously, I was struggling and frustrated; working with Clojure and LLMs was a constant source of hallucinated functions, invalid syntax, and a generally unpleasant experience, with a lot of rework and misdirection. Clojure MCP really changed that. Thanks a ton to Bruce Hauman for building Clojure MCP and Clojure MCP Light and releasing them as open-source tools.

Skills, Prompts and OpenCode

The next step I took toward achieving better LLM output was to evaluate how Anthropic's skill system works. I also developed a tool I ended up calling Clojure Skills. Clojure Skills was envisioned as a SQLite database with a command-line interface that would allow the LLM agent and the human to search through a set of anthropic-style skills. These skills would inform the LLM agent about patterns, idioms, specific libraries, and whatnot, hoping that it would produce much better output and that I would spend a lot less time debugging. This was also the point when I started experimenting a little with tools like OpenCode.

What's interesting about OpenCode is that it lets you easily define your own system prompt. So at this point I defined a system prompt for building a skill that would dynamically load the library onto the Clojure REPL and build a skill for that library.

Here is an example of using OpenCode with a custom system prompt:

{
  "$schema": "https://opencode.ai/config.json",
  "agent": {
    "clojure": {
      "description": "Expert Clojure developer with REPL-driven workflow",
      "model": "anthropic/claude-sonnet-4",
      "prompt": "{file:./SYSTEM.md}"
    }
  },
  "default_agent": "clojure"
}

It was during the process of building my own skill system and using those skills day-to-day that I noticed a fundamental issue: over long LLM sessions with large context windows, the knowledge and the skill didn't stay very sticky. The LLM initially uses the correct skills and patterns, but eventually it starts to forget, ignore, and just do its own thing.

After some research I discovered that this problem is documented in the literature. In their 2023 paper "Lost in the Middle: How Language Models Use Long Contexts", Liu et al. demonstrated that LLMs effectively have a U-shaped memory curve: the most recent conversation turns and the system prompt are weighted more heavily than the middle of the conversation. Consequently, when skills are loaded mid-conversation, the LLM often fails to follow them.

I began experimenting within the clojure-skills system with a concept I call "prompt packing." I saw on Reddit that other people were putting all the knowledge of their code base directly into the system prompt. That made immediate sense given what we know about context windows. So I tried both inlining and referencing skills in the system prompt. OpenCode let me carefully curate the skills in my system prompt.

With this approach I achieved a new level of effectiveness: I was able to one-shot more and more tasks with the LLM than I ever could with Claude Code alone.

System Prompts Refinements and Prompt Evals

At this point in our development of Korey, it was time to tweak the system prompt for Korey itself. I was lucky enough to be assigned this task at work, and I spent a couple of weeks understanding how people evaluate system prompts and how they make them more effective for their task. I then drew the connection between my engineering system prompt and a prompt-evaluation system. This was key to our development for iterating and evaluating how we use LLMs and how our system prompt can become more effective. A helpful resource for this type of research is hamel.dev. Hamel Husain is a great communicator and writer. He was critical in my understanding of how to think about refining and defining system prompts, especially for working with code. Thanks, Hamel.

At this point, I started working on what eventually became my Clojure system prompt project. This was a prompt that we iterated a lot internally at Shortcut, and I have released it as an open-source project. I hope other Clojure developers will find this a helpful starting point to devise their own system prompts. One thing you can notice is I use the REPL extensively to ground truth, aka prevent hallucinations, before the LLM agent writes any code. This was key for transforming what was an incredibly frustrating experience into a much smoother and more graceful LLM interaction.

Another step I took was to deepen the ability of the LLM to use the Clojure platform itself. Clojure is designed to be interacted with from the REPL. This turns out to be a huge advantage when working with an LLM. For example, instead of defining a new skill for each library, I taught the LLMs about clojure.repl. This allows the LLM to dynamically explore any Clojure library on the REPL. Individual skills or lessons became less important, and the platform itself served as a dynamic feedback loop. Here is an example of that

<discovering-functions>
The REPL is your documentation browser. Explore before coding:

List all public functions in a namespace:
```shell
clj-nrepl-eval -p 7889 "(clojure.repl/dir clojure.string)"
# Output: blank? capitalize ends-with? escape includes? index-of join
#         lower-case replace reverse split split-lines starts-with? trim ...
```

Get detailed documentation for any function:
```shell
clj-nrepl-eval -p 7889 "(clojure.repl/doc map)"
# Output:
# -------------------------
# clojure.core/map
# ([f] [f coll] [f c1 c2] [f c1 c2 c3] [f c1 c2 c3 & colls])
#   Returns a lazy sequence consisting of the result of applying f to
#   the set of first items of each coll, followed by applying f to the
#   set of second items in each coll, until any one of the colls is
#   exhausted. Any remaining items in other colls are ignored...
```

Search for functions by name pattern:
```shell
clj-nrepl-eval -p 7889 "(clojure.repl/apropos \"split\")"
# Output: (clojure.string/split clojure.string/split-lines split-at split-with)
```

Search documentation text:
```shell
clj-nrepl-eval -p 7889 "(clojure.repl/find-doc \"regular expression\")"
# Shows all functions whose documentation mentions "regular expression"
```

Read function source code:
```shell
clj-nrepl-eval -p 7889 "(clojure.repl/source filter)"
# Shows the actual implementation - great for understanding edge cases
```

Despite these breakthroughs, the development flow was still not what I wanted it to be. First, OpenCode seems to consume a large amount of memory. It is a real bummer when you're working with a context window that you have crafted over thirty or forty minutes, and the LLM agent crashes because of the harness. It's a very frustrating experience.

Additionally, I've noticed that OpenCode tries to minimize the output of tool calls. This follows the general industry pattern we see in Claude Code, where certain information is hidden from the engineer. It's unclear to me what the exact design goals are, but I believe they're trying to minimize the information the developer sees so as not to overwhelm them.

However, when you're refining your system prompts and thinking carefully about your LLM interactions, this hiding of information becomes a real hindrance. I want to know that my tool calls are correct, that my edits are clear, and that my system functions as effectively as possible.

At this point I read a blog post about a new agent, pi-agent, and I was hooked.

PI Agent and liberation

Coming from an LLM agent harness like Claude Code that attempts to hide what the LLM is doing to a simple LLM harness that shows you everything felt like a liberating experience to me. Not only that, but pi-agent encourages you to solve your own problems, just like Emacs does. If there's a tool or functionality that you need that pi-agent doesn't provide, you simply build your own plugin. I have written a couple of plugins and used several more from the community.

One plugin I wrote is something to track my energy usage while working with LLMs. Like many, I am deeply concerned about LLMs' effects on our planet. My plugin tracks how much carbon my interactions are generating and compares that to standard car use.

What I value most about pi-agent is its reliability over long contexts. It also clearly indicates whether a tool call succeeded or failed with a green or red output. It's very minimal, does not use sub-agents, and allows me to focus on what my LLM is doing. As I follow along, I can tighten my iteration loop even more.

Current stack and future directions

My current stack is Clojure MCP Light, pi-agent, and my own system prompt. I plan to continue iterating on the Clojure system prompt and develop tools that make working with Clojure from LLMs more effective. I've also begun to experiment more and more with open-weight models, including the excellent Kimi 2.5 model from Moonshot AI, which is effectively my day-to-day driver now along with Sonnet 4.5.

There are a bunch of future directions that I would love to explore if given the time and opportunity. The rise of very effective open-weight models like Kimi 2.5 and MinMax 2.5 opens the possibility to post-train these models on Clojure and on our code base. Theoretically, this could allow us to not even have to use custom system prompts, skills, or specialized tools. We could effectively train our own Kimi Clojure or MinMax Clojure model that would have all of our best practices baked in. Of course, this is theoretical; I've done a little research, and there seems to be strong evidence that this would work. However, we won't really know until we try it out.

I also think it would be worthwhile to begin to curate all the tools that individual Clojure developers are building to work with Clojure and building an ecosystem and documentation around this.

Conclusion

I think there is a tendency among certain users of LLMs to limit the platforms and languages that we use with the LLMs. Out of the box, there are good reasons to consider this. If I were working on a greenfield project, I might consider using something like Python. However, I believe software decisions should serve the needs of the people who work on it and with it, not the needs of the LLM that might play a role creating it.

Ultimately, human beings are responsible for managing these software systems, and human knowledge still, in my view, trumps LLM statistical output. Taking that into consideration, the artifact produced by the LLM process is still a critical part of software development. I prefer to debug and deploy Clojure JVM artifacts, and I know many other organizations do as well.

Also, I think there are important reasons not to abandon all the lessons we've learned over the last twenty years of using Clojure. Clojure itself eliminates a whole set of common engineering problems like statefulness. I don't see the advantage of going back to a language where we have to use mutable state for iteration, for example, or a language where functional idioms are not the default.

My experiences with using an LLM for software engineering and developing an LLM have taught me that, rather than constraining the platforms we use and adopt, the LLM era could actually free us. The idea that LLMs are only good at something because it's in the training set ignores the tools, skills, and system prompts that we can apply post-training.

We can craft and refine LLMs for our specific platform. Not only that, there is something beautiful about LLMs: instead of narrowing how we work, they can allow people to work in very different ways.

I hope this blog post was insightful and informative, and I hope that my experience and our experience at Shortcut can help you craft and shape an LLM experience that solves your particular engineering problems. Thank you for reading.

References

Katzy, J., & Izadi, M. (2023). On the Impact of Language Selection for Training and Evaluating Programming Language Models. arXiv preprint arXiv:2308.13354. https://arxiv.org/abs/2308.13354

Liu, N. F., Lin, K., Hewitt, J., Paranjape, A., Bevilacqua, M., Petroni, F., & Liang, P. (2023). Lost in the Middle: How Language Models Use Long Contexts. arXiv preprint arXiv:2307.03172. https://arxiv.org/abs/2307.03172

Permalink

Clojure Notebooks

Code

;; clojure_notebook.clj

(+ 1 1)

(println "Hello World!")

(* 2 (+ 1 1))

(* 6 7) Write code to find if number is prime

(defn prime? [n]
  (and (> n 1)
       (not (some #(zero? (mod n %)) (range 2 n)))))

(prime? 17)


(prime? 12)

Notes

Permalink

Week Notes 2026.08

A look at the current state of the art for Clojure web development, alternatives to Datomic, and the static doc site is done.

Permalink

Reconstructing Biscuit in Clojure

Authority in Agentic Systems

Over the past few months, experimenting with agentic systems, my thinking kept coming back to one question: how does authority actually move between components? That led me to OCapN and structural authority, then to interpreting OCapN in cloud-native architectures. Those articles are below.



Most systems answer this with identity. You authenticate, get a role, and a policy engine decides what you can do. This tends to work well when everything is centralized. But distributed systems can put pressure on this model. Consider two cases in particular.

An agent that needs to make an authorization decision offline. Or an agent that needs to delegate a narrow slice of its authority to another agent, across a service boundary. In both cases, the token has to carry enough information to be evaluated on its own. Identity alone tends not to be enough for this.

This is the tension I wanted to explore: what if authority were something you carry explicitly, rather than something a central engine derives for you?


Thanks for reading Taorem! Subscribe for free to receive new posts and support my work.


Two Mental Models

Identity-first: You prove who you are. A policy engine looks up what you are allowed to do. Delegation means giving someone a role. Restricting authority means writing more policy rules.

Capability-first: You carry a token. The token contains the authority directly. Delegation means giving someone a narrower version of your token. In this model, the token is designed to enforce the constraints.

The difference tends to matter in distributed systems. With identity-first, you typically need the policy engine available at the point of evaluation. With capability-first, the token is designed to be self-contained, you can verify it without calling back to a central service.

Biscuit is one concrete implementation of this model. It is a token format where authorization logic, facts, rules, and checks, travels inside the token itself, expressed in a Datalog-style reasoning language

What Biscuit Does

A Biscuit token contains three things: facts, rules, and checks.

Facts are statements about the world. “Alice has the role of agent.” “Bob is an internal agent who owns a web-search tool.”

Rules define what can be derived from facts. “If a user has the role of agent, and a target is a known internal agent, that user can read that target.”

Checks are conditions that must hold for the token to be valid. “This token must verify that Alice can use Bob’s web-search tool.”

When you verify a Biscuit token, each block is evaluated within its own scope. Facts in one block are not automatically visible to another. Checks in a block are evaluated against only the facts that block can see. If all checks pass and all signatures are valid, the token is valid.

Delegation works by appending a new block. You can add facts or checks, but existing blocks cannot be changed without invalidating the token, because each block is cryptographically signed. Because each block’s checks are evaluated in isolation, a later block cannot bypass a constraint set by an earlier one. In Biscuit's design, that guarantee is structural, not policy-based.

There are tradeoffs worth considering, and some of them are discussed in the open questions below. The most immediate is complexity, Biscuit uses a Datalog-style reasoning model. Many developers are not familiar with it. The mental model is different from role-based access control or a tool like OPA. This is a real cost.



Rebuilding the Core: Kex

I wanted to understand Biscuit by building a minimal version of it. Not a full implementation. Not production-ready. Just the core ideas, small enough to inspect.

The result is kex, written in Clojure.

Why Clojure? Because facts, rules, and proofs map naturally to immutable maps and vectors. The whole system stays visible. You can evaluate a token in the REPL, inspect the derived facts, and follow the reasoning step by step.

Facts

A fact is a vector:

[:role "alice" :agent]

Facts live inside blocks:

{:facts [[:user "alice"]
         [:role "alice" :agent]]}

Nothing is evaluated yet. This is just structured data.

Rules

A rule describes how to derive new facts:

{:id   :agent-can-read-agents
 :head [:right ?user :read ?agt]
 :body [[:role ?user :agent]
        [:internal-agent ?agt]]}

If [:role "alice" :agent] and [:internal-agent "bob"] exist, this rule derives [:right "alice" :read "bob"]. Rules keep firing until nothing new appears.

Kex implements a minimal Datalog engine using plain Clojure data structures. This tends to keep the system easy to inspect, but recursive rules and negation are not supported. That is a deliberate trade.

Checks

A check is a query that must return at least one result:

{:id    :can-read-web-search
 :query '[[:right "alice" :read "web-search"]]}

If the query returns nothing, the token is invalid. In kex, all facts from all blocks are collected first, then all rules are applied to derive new facts, and finally checks are evaluated against the full combined fact set. In the example above, the check is satisfied because the :can-implies-right rule, added in the delegation block, derives [:right "alice" :read "web-search"] from the [:can "alice" :read "web-search"] fact. Biscuit evaluates each block within its own scope, blocks cannot see each other's private facts. Kex does not implement this isolation.

Issuing a Token

The issuer creates the first block. It defines who Alice is and what agents are allowed to do.

(def token
  (kex/issue
    {:facts  [[:user "alice"] 
              [:role "alice" :agent]]
     :rules  '[{:id   :agent-can-read-agents
                :head [:right ?user :read ?agt]
                :body [[:role ?user :agent]
                       [:internal-agent ?agt]]}]
     :checks []}
    {:private-key (:priv keypair)}))

This signs the block and returns a token. The block cannot be changed after this point.

Delegation

A second service appends a new block. It adds facts about what Alice can access, and a rule that derives read rights from those facts. Because kex collects all facts and rules from all blocks into a single pool before evaluation, this block's facts and rules will be combined with the first block's during derivation.

(def delegated-token
  (kex/attenuate
    token
    {:facts  [[:internal-agent "bob"]
              [:can "alice" :read "web-search"]]
     :rules  '[{:id   :can-implies-right
                :head [:right ?user :read ?res]
                :body [[:can ?user :read ?res]]}]
     :checks []}
    {:private-key (:priv keypair)}))

A new block is appended. The old block is untouched.

Adding a Check

A third party appends one more block. It adds nothing but a check. This token is only valid if Alice can access Bob's web-search tool.

(def auth-token
  (kex/attenuate
    delegated-token
    {:facts  []
     :rules  []
     :checks [{:id :can-read-web-search
               :query '[[:right "alice" :read "web-search"]]}]}
    {:private-key (:priv keypair)}))

Verification and Explanation

(kex/verify auth-token {:public-key (:pub keypair)})

(def decision (kex/evaluate auth-token :explain? true))
(:valid? decision)
(:explain decision)

The explain output shows which rules fired and which facts satisfied each check. You can turn this into a graph:

(kex/graph (:explain decision))

In kex, authorization tends to become something you can read, not just trust.


Thanks for reading Taorem! Subscribe for free to receive new posts and support my work.


What Kex Does Not Do

Kex does not handle revocation, recursive rules, or the full Biscuit serialization format. It is not performance optimized. Do not use it in production.

It also does not fully enforce attenuation. A new block can add broader facts that expand authority if no check prevents it. In Biscuit, block isolation prevents this, a new block cannot see or override facts from another block’s private scope. In kex, that isolation is not implemented.


The full source is available here: https://github.com/serefayar/kex


Open Questions

Building kex made the capability model concrete, but it also made some hard problems more visible.

Revocation and offline verification are in tension. If a token is self-contained and does not need a central service, how do you invalidate it before it expires? Biscuit has partial answers here, but the problem does not go away. It shifts.

Token size grows with each delegation. In systems with deep delegation chains, this can become a practical concern.

Ecosystem fit is also an open question. Most existing infrastructure expects JWT or OAuth tokens. Biscuit does not slot in easily.

Explainability is useful in small systems. Whether it scales to the rule complexity of a real authorization policy is a different question.

And the bigger question: do capability-first models actually solve distributed authorization, or do they mostly reframe it? I do not have a confident answer. Kex is one small experiment in that direction.

Permalink

State of Clojure 2025 Results

Recently, we completed the 2025 State of Clojure survey. You can find the full survey results in this report.

In the report and the highlights below, "Clojure" is often used to refer to the whole ecosystem of Clojure and its dialects as a whole. When relevant, specific dialects are mentioned by name, such as ClojureScript, Babashka, ClojureCLR, etc.

See the following sections for highlights and selected analysis:

Demographics

80 countries represented

80 different countries were represented by respondents to the State of Clojure Survey!

Responses by Country

Responses by country

The Top 10 countries, by count:

1. United States
2. Brazil
3. Germany
4. United Kingdom
5. Finland

6. Sweden
7. France
8. Norway
9. Canada
10. Poland

In fact, the top 4 countries constituted 50.1% of the respondents, so by the numbers, the United States, Brazil, Germany, and the United Kingdom have the same number of Clojure users as the rest of the world.

What if we adjust for population? We can see where Clojure is most concentrated per capita.

1. Finland
2. Norway
3. Sweden
4. Denmark
5. Switzerland

6. Serbia
7. Ireland
8. Netherlands
9. Czech Republic
10. Uruguay

Northern Europe has an especially high concentration of Clojurists.

Responses by Per Capita

Responses for Europe per capita

Also, despite the population differences, Austria, Australia, United States, Brazil, and Canada all have a similar concentration of Clojurists.

82% of Clojure developers have 6 or more years of professional programming experience

Experienced developers continue to be well represented in the Clojure community.

Question: How many years have you been programming professionally?

Professional experience

Clojure attracts developers across a wide range of professional experience

Clojure isn’t just appealing to highly experienced professional developers. Clojure also attracts developers with little to no professional experience. New Clojure developers are from a wide range of professional programming experience.

Professional experience for those with ≤ 1 year of Clojure experience

Professional experience for new Clojurists

Most Clojure developers use Clojure as their primary language

About 2/3 of the respondents use Clojure as their primary language. When Clojure isn’t primary, popularity seems to influence language choice more than a specific language attribute (such as a functional style).

Question: What was your PRIMARY language in the last year?

Top primary languages for Clojurists
Other primary languages for Clojurists

Developer Satisfaction

10% of Clojure developers indicated that they only used Clojure. All others indicated at least one other language they used. This choice, like the primary language, appears to be influenced by popularity, although functional languages (eg. Elixir, Lisp, Scheme/Racket, etc.) appear to be overrepresented versus their general popularity.

Question: What programming languages have you used in the last year? (select all)

Top languages used with Clojure
Other languages used with Clojurists

1 in 10 Clojure developers would quit programming without Clojure

The results below are for developers that selected Clojure and its dialects as their primary language.

Question: If you couldn’t use Clojure, what language would you use instead?

Top alternatives to Clojure
Other alternatives to Clojure

Unsurprisingly, the most popular languages are well represented in the top choices: Java, Python, TypeScript, Go, etc., but notice the functional languages are overrepresented versus their general popularity: Elixir, Common Lisp, Scheme/Racket, Haskell, and Erlang.

The design of the Elixir language was influenced by Clojure, so it makes sense that it would stand out as a Clojure alternative versus other functional languages.

Clojure developers are very likely to recommend Clojure to others.

70% of the respondents said they were very likely to recommend Clojure with only 8% saying they would not.

Question: How likely is it that you would recommend Clojure to a friend or colleague?

Net Promoter Score

Industries and Applications

Survey respondents have nearly as much fun with Clojure (52% for hobbies) as more serious uses (71% for work).

Question: How would you characterize your use of Clojure today? (select all)

Use of Clojure today

Fintech, Enterprise Software, and Healthcare are the top industries for Clojure at over 51% combined.

Clojure is used across a range of industries, but Financial Services, Enterprise Software and Healthcare stand out as the top ones. Fintech is 2.5x more popular for Clojure than Enterprise Software and over 4x more popular than Healthcare.

Question: What primary industry do you develop for?

Top industries
Other industries

Clojure is used at large companies and small companies alike.

16% of Clojurists are solo developers. 55% are in an organization of 100 people or less. 26% are in an organization larger than 1000 people—​many are likely part of Nubank, the world’s largest digital-only financial services platform, which employs thousands of Clojure developers.

Question: What is your organization size?

Organization Size

New Users

Clojure continues to attract and retain developers.

15% of respondents have used Clojure for one year or less. That’s roughly equivalent to the 16% that have used Clojure 11-15 years. With 16+ years of experience, 3% of the Clojure community is made up of Clojure’s earliest adopters.

Question: How long have you been using Clojure?

Years of Clojure experience

Using equally sized buckets, it becomes clear that about half the community has 5 or less years of Clojure experience and the other half has 6 or more years.

Years of Clojure experience

Clojure experience bucketed

Functional programming, work, Lisp heritage, and Rich Hickey’s talks are the top reasons for investigating Clojure.

The survey asked developers with ≤ 1 year of Clojure experience to select all the factors that first prompted them to investigate Clojure.

Question: Why did you first start looking at Clojure? (select all)

Seeking a functional programming language

40.20%

Use at work

39.70%

Seeking a modern LISP

39.20%

Inspired by conference talk or video by Rich Hickey or others

32.16%

Seeking a more concise/expressive language on the JVM

14.57%

Seeking a better language for web / full stack programming

13.07%

Inspired by programming writings by prominent authors

12.56%

Enjoyed the community

9.55%

Seeking a language for safe concurrent programming

8.54%

Introduced by a friend or colleague

8.54%

Inspired by using a tool or framework written in Clojure

7.04%

Other (please specify)

6.53%

Business advantages like leverage, hiring, pay

3.52%

Interested in doing music / art programming

2.51%

Use in a university class

1.01%

Nearly half of new Clojure developers are unfamiliar with structured editing.

Structured editing allows a developer to efficiently edit Clojure code while keeping parenthesis and other delimiters balanced. It is especially useful for Lisp-style syntax where the distance between those delimiters ("on the outside") can span many lines of code.

As you can see below, only 19% of experienced Clojurists don’t use it ("manual") or are "not sure" about structured editing. For the inexperienced group, a full 48% don’t use it or are not sure.

Question: Which method do you use to edit Clojure code while balancing parentheses, brackets, etc? (Structured editing)

Respondents with 2 or more years of Clojure experience

Clojure development environment

Respondents with 1 year or less of Clojure experience

Clojure development environment for new Clojurists

Clojure Dialects and Tools

3 out of 5 respondents indicated they use Babashka, which edged out ClojureScript for the #2 spot for the second year in a row.

Question: Which dialects of Clojure have you used in the last year? (select all)

Top Clojure dialects
Other Clojure dialects

Emacs still holds the top spot overall, but new Clojurists are much more likely to use VS Code with Calva.

Across all respondents, Emacs is the most popular, although there is a near perfect 50-50 split between Emacs + Vim and all the others.

Question: What is your primary Clojure development environment?

Clojure development environment

For Clojure developers with one year or less of Clojure experience, Emacs and VS Code essentially trade places.

Respondents with 1 year or less of Clojure experience

Clojure development environment for new Clojurists

70% of Clojure developers have used AI tools for software development, and 12% are considering it.

The industry-wide surge of AI tooling can be seen in the Clojure community. Although a huge majority of Clojure developers have used AI tooling, a disinterested 18% are quite content without it.

Question: Have you used AI tools for software development?

AI coding tool usage

Final Comments

44% of respondents took the time to express appreciation.

After a very long survey, nearly half of the respondents took even more time to express appreciation for others in the Clojure community. You can read their many, many words of appreciation in the full results of the 2025 survey.

Question: Who do you appreciate in the Clojure community and why?

Appreciative responses

In the spirit of thanks, we would like to thank you again for using Clojure and participating in the survey!

Previous Years

We’re celebrating our 15th State of Clojure Survey! 🎉 🥳

What better way to celebrate than by looking back at the years gone by? You can find the full results for this and prior years at the links below:

Permalink

Tetris-playing AI the Polylith way - Part 3

Tetris AI

The focus in this third part of the blog series is to implement an algorithm that computes all valid moves for a piece (Tetromino) in its starting position. We are refining our domain model and improving the readability of parts of the codebase, while continuing to implement the code in Clojure and Python using the component-based Polylith architecture.

Earlier parts:

  • Part 1 - Places a piece on a board. Shows the differences between Clojure and Python and creates the piece and board components.
  • Part 2 - Implements clearing of completed rows. Shows how to get fast feedback when working REPL-driven.

The resulting source code from this post:

Tetris Variants

Tetris has been made in several different variants, such as the handheld Game Boy, the Nintendo NES console, and this Atari arcade game, which I played an unhealthy amount of in my younger days at a pool hall that no longer exists!

Each variant behaves slightly differently when it comes to colours, starting positions, rotation behaviour, and so on.

In most Tetris variants, the pieces start in these rotation states (lying flat) before they start falling:

Tetris pieces

Where on the board the pieces start also varies. For instance, on Nintendo NES and Atari Arcade they start in the fifth x-position, while on Game Boy they start in the fourth:

Start position

In these older versions of Tetris, the pieces rotate only counterclockwise, unlike in some newer games where you can rotate both clockwise and counterclockwise.

The following table compares how pieces rotate across the three mentioned variants:

Rotation table

On Atari, pieces are oriented toward the top-left corner (except the vertical I), while on the other two they mostly rotate around their centre.

In our code, we represent a piece as four [x y] cells:

[[0 1] [1 1] [2 1] [1 2]]

This representation is easy for the code to work with, but poorly communicates the shape of a piece to a human.

The main rule is that code should be written to be easy to understand for the people who read and change it (humans and AI agents).

Let us therefore define a piece like this instead:

(def T0 [&apos---
         &aposxxx
         &apos-x-])

Python:

T0 = [
    "---",
    "xxx",
    "-x-"]

Now we can define all seven pieces and their rotation states for Game Boy (Python code is almost identical):

(ns tetrisanalyzer.piece.settings.game-boy
  (:require [tetrisanalyzer.piece.shape :as shape]))


(def O0 [&apos----
         &apos-xx-
         &apos-xx-
         &apos----])

(def I0 [&apos----
         &apos----
         &aposxxxx
         &apos----])

(def I1 [&apos-x--
         &apos-x--
         &apos-x--
         &apos-x--])

(def Z0 [&apos---
         &aposxx-
         &apos-xx])

(def Z1 [&apos-x-
         &aposxx-
         &aposx--])

(def S0 [&apos---
         &apos-xx
         &aposxx-])

(def S1 [&aposx--
         &aposxx-
         &apos-x-])

(def J0 [&apos---
         &aposxxx
         &apos--x])

(def J1 [&apos-xx
         &apos-x-
         &apos-x-])

(def J2 [&aposx--
         &aposxxx
         &apos---])

(def J3 [&apos-x-
         &apos-x-
         &aposxx-])

(def L0 [&apos---
         &aposxxx
         &aposx--])

(def L1 [&apos-x-
         &apos-x-
         &apos-xx])

(def L2 [&apos--x
         &aposxxx
         &apos---])

(def L3 [&aposxx-
         &apos-x-
         &apos-x-])

(def T0 [&apos---
         &aposxxx
         &apos-x-])

(def T1 [&apos-x-
         &apos-xx
         &apos-x-])

(def T2 [&apos-x-
         &aposxxx
         &apos---])

(def T3 [&apos-x-
         &aposxx-
         &apos-x-])

(def pieces [[O0]
             [I0 I1]
             [Z0 Z1]
             [S0 S1]
             [J0 J1 J2 J3]
             [L0 L1 L2 L3]
             [T0 T1 T2 T3]])

(def shapes (shape/shapes pieces))

The shapes function at the end converts the pieces into the format the code uses:

[;; O
 [[[1 1] [2 1] [1 2] [2 2]]]
 ;; I
 [[[0 2] [1 2] [2 2] [3 2]]
  [[1 0] [1 1] [1 2] [1 3]]]
 ;; Z
 [[[0 1] [1 1] [1 2] [2 2]]
  [[1 0] [0 1] [1 1] [0 2]]]
 ;; S
 [[[1 1] [2 1] [0 2] [1 2]]
  [[0 0] [0 1] [1 1] [1 2]]]
 ;; J
 [[[0 1] [1 1] [2 1] [2 2]]
  [[1 0] [2 0] [1 1] [1 2]]
  [[0 0] [0 1] [1 1] [2 1]]
  [[1 0] [1 1] [0 2] [1 2]]]
 ;; L
 [[[0 1] [1 1] [2 1] [0 2]]
  [[1 0] [1 1] [1 2] [2 2]]
  [[2 0] [0 1] [1 1] [2 1]]
  [[0 0] [1 0] [1 1] [1 2]]]
 ;; T
 [[[0 1] [1 1] [2 1] [1 2]]
  [[1 0] [1 1] [2 1] [1 2]]
  [[1 0] [0 1] [1 1] [2 1]]
  [[1 0] [0 1] [1 1] [1 2]]]]

The test for the shape function looks like this:

(ns tetrisanalyzer.piece.shape-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.shape :as shape]))

(deftest converts-a-piece-shape-grid-to-a-vector-of-xy-cells
  (is (= [[2 0]
          [1 1]
          [2 1]
          [1 2]]
         (shape/shape [&apos--x-
                       &apos-xx-
                       &apos-x--
                       &apos----]))))

Python:

from tetrisanalyzer.piece.shape import shape


def test_converts_a_piece_shape_grid_to_a_list_of_xy_cells():
    assert [[2, 0],
            [1, 1],
            [2, 1],
            [1, 2]] == shape(["--x-",
                              "-xx-",
                              "-x--",
                              "----"]
    )

Implementation in Clojure:

(ns tetrisanalyzer.piece.shape)

(defn cell [x character y]
  (when (= \x character)
    [x y]))

(defn row-cells [y row]
  (keep-indexed #(cell %1 %2 y)
                (str row)))

(defn shape [piece-grid]
  (vec (mapcat identity
               (map-indexed row-cells piece-grid))))

(defn shapes [piece-grids]
  (mapv #(mapv shape %)
        piece-grids))

If you are new to Clojure, here are some explanatory examples of a couple of the functions:

(map-indexed vector ["I" "love" "Tetris"])

;; ([0 "I"] [1 "love"] [2 "Tetris"])

The map-indexed function iterates over "I", "love", and "Tetris", and builds a new list where each element is created by calling vector with the index, which is equivalent to:

(list (vector 0 "I")
      (vector 1 "love")
      (vector 2 "tetris"))

;; ([0 "I"] [1 "love"] [2 "Tetris"])

The function keep-indexed works in the same way, but only keeps values that aren&apost nil, hence the use of when:

;; %1 = first argument (index)
;; %2 = second argument (value)
(keep-indexed #(when %2 [%1 %2]) 
              ["I" nil "Tetris"])

;; ([0 "I"] [2 "Tetris"])

Implementation in Python:

def shape(piece_grid):
    return [
        [x, y]
        for y, row in enumerate(piece_grid)
        for x, ch in enumerate(row)
        if ch == "x"]

def shapes(pieces_grids):
    return [
        [shape(piece_grid) for piece_grid in piece_grids]
        for piece_grids in pieces_grids]

Here we use list comprehension to convert the data into [x, y] cells. The enumerate function is equivalent to Clojure’s map-indexed, in that it adds an index (0, 1, 2, …) to each element.

Domain Modelling

The new code that calculates the valid moves for a piece in its starting position has to live somewhere. We need to be able to move and rotate a piece, and check whether the target position on the board is free.

In object-oriented programming we have several options. We could write piece.set(board), board.set(piece), or maybe move.set(piece, board), while making every effort not to expose the internal representation.

In functional programming, we have more freedom and don&apost try to hide how we represent our data. The fact that the board is stored as a two-dimensional vector is no secret, and it isn’t just board that can create updated copies of this two-dimensional vector.

Code usually belongs where we expect to find it. We have the function set-piece, which, according to this reasoning, should live in piece, so I moved it from board where I&aposd put it earlier. The new placements function also goes in piece, since it&aposs about finding valid moves for a piece. Our domain model now looks like this:

Components

Inside each component we list what belongs to its interface (what&aposs public), and the arrow shows that piece calls functions in board.

We split the implementation across the namespaces move, placement, and visit, which we put in the move package:

▾ tetris-polylith
  ▸ bases
  ▾ components
    ▸ board
    ▾ piece
      ▾ src
        ▸ settings
        ▾ move
          move.clj
          placement.clj
          visit.clj
        bitmask.clj
        interface.clj
        piece.clj
        shape.clj
      ▾ test
        ▾ move
          move_test.clj
          placement_test.clj
          visit_test.clj
        piece_test.clj
        shape_test.clj
  ▸ development
  ▸ projects

The move-test looks like this:

(ns tetrisanalyzer.piece.move.move-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.piece :as piece]
            [tetrisanalyzer.piece.move.move :as move]
            [tetrisanalyzer.piece.bitmask :as bitmask]
            [tetrisanalyzer.board.interface :as board]
            [tetrisanalyzer.piece.settings.atari-arcade :as atari-arcade]))

(def x 2)
(def y 1)
(def rotation 0)
(def S piece/S)
(def shapes atari-arcade/shapes)
(def bitmask (bitmask/rotation-bitmask shapes S))
(def piece (piece/piece S rotation shapes))

(def board (board/board [&aposxxxxxxxx
                         &aposxxx--xxx
                         &aposxx--xxxx
                         &aposxxxxxxxx]))

(deftest valid-move
  (is (= true
         (move/valid-move? board x y S rotation shapes))))

(deftest valid-left-move
  (is (= [2 1 0]
         (move/left board (inc x) y S rotation nil shapes))))

(deftest invalid-left-move
  (is (= nil
         (move/left board x y S rotation nil shapes))))

(deftest valid-right-move
  (is (= [2 1 0]
         (move/right board (dec x) y S rotation nil shapes))))

(deftest invalid-right-move
  (is (= nil
         (move/right board x (dec y) S rotation nil shapes))))

(deftest unoccupied-down-move
  (is (= [[2 1 0] nil]
         (move/down board x (dec y) S rotation nil shapes))))

(deftest down-move-hits-ground
  (is (= [nil [[2 1 0]]]
         (move/down board x y S rotation nil shapes))))

(deftest valid-rotation
  (is (= [2 1 0]
         (move/rotate board x y S (dec rotation) bitmask shapes))))

(deftest invalid-rotation-without-kick
  (is (= nil
         (move/rotate board (inc x) y S (inc rotation) bitmask shapes))))

(deftest valid-rotation-with-kick
  (is (= [2 1 0]
         (move/rotate-with-kick board (inc x) y S (inc rotation) bitmask shapes))))

(deftest invalid-move-outside-board
  (is (= false
         (move/valid-move? board 10 -10 S rotation shapes))))

The first test, valid-move, checks that the S piece:

[&apos-xx
 &aposxx-]

Can be placed at position x=2, y=1, on the board:

[&aposxxxxxxxx
 &aposxxx--xxx
 &aposxx--xxxx
 &aposxxxxxxxx]

Beyond that, we test various valid moves and rotations into the empty area, plus invalid moves outside the board.

In Tetris there&aposs something called kick, or wall kick. When you rotate a piece and that position is occupied on the board, one step left is also tried (x-1). On Nintendo NES this is turned off, while it&aposs enabled in the other two variants we support here. In newer Tetris games, other placements besides x-1 are sometimes tested as well.

The implementation looks like this:

(ns tetrisanalyzer.piece.move.move
  (:require [tetrisanalyzer.piece.piece :as piece]))

(defn cell [board x y [cx cy]]
  (or (get-in board [(+ y cy) (+ x cx)])
      piece/X))

(defn valid-move? [board x y p rotation shapes]
  (every? zero?
          (map #(cell board x y %)
               (piece/piece p rotation shapes))))

(defn left [board x y p rotation _ shapes]
  (when (valid-move? board (dec x) y p rotation shapes)
    [(dec x) y rotation]))

(defn right [board x y p rotation _ shapes]
  (when (valid-move? board (inc x) y p rotation shapes)
    [(inc x) y rotation]))

(defn down
  "Returns [down-move placement] where:
   - down-move: next move when moving down or nil if blocked
   - placement: final placement if blocked, or nil if can move down"
  [board x y p rotation _ shapes]
  (if (valid-move? board x (inc y) p rotation shapes)
    [[x (inc y) rotation] nil]
    [nil [[x y rotation]]]))

(defn rotate [board x y p rotation bitmask shapes]
  (let [new-rotation (bit-and (inc rotation) bitmask)]
    (when (valid-move? board x y p new-rotation shapes)
      [x y new-rotation])))

(defn rotate-with-kick [board x y p rotation bitmask shapes]
  (or (rotate board x y p rotation bitmask shapes)
      (rotate board (dec x) y p rotation bitmask shapes)))

(defn rotation-fn [rotation-kick?]
  (if rotation-kick?
    rotate-with-kick
    rotate))

The functions are fairly straightforward, so let us instead look at the code that helps us keep track of which moves have already been visited:

(ns tetrisanalyzer.piece.move.visit)

(defn visited? [visited-moves x y rotation]
  (if-let [visited-rotations (get-in visited-moves [y x])]
    (not (zero? (bit-and visited-rotations
                         (bit-shift-left 1 rotation))))
    true)) ;; Cells outside the board are treated as visited

(defn visit [visited-moves x y rotation]
  (assoc-in visited-moves [y x] (bit-or (get-in visited-moves [y x])
                                        (bit-shift-left 1 rotation))))

Calling the standard bit-shift-left function returns a set bit in one of the four lowest bits:

rotationbit
00001
10010
20100
31000

These "flags" are used to mark that we&aposve visited a given [x y rotation] move on the board. Note that we pass a "visited board" (visited-moves) into visit and get back a copy where the [x y] cell has a bit set for the given rotation. This “copying” is very fast and memory-efficient, see “structural sharing” under Data Structures.

The tests look like the following:

(ns tetrisanalyzer.piece.move.visit-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.move.visit :as visit]))

(def x 2)
(def y 1)
(def rotation 3)
(def unvisited [[0 0 0 0]
                [0 0 0 0]])

(deftest move-is-not-visited
  (is (= false
         (visit/visited? unvisited x y rotation))))

(deftest move-is-visited
  (let [visited (visit/visit unvisited x y rotation)]
    (is (= true
           (visit/visited? visited x y rotation)))))

Python:

from tetrisanalyzer.piece.move.visit import is_visited, visit

X = 2
Y = 1
ROTATION = 3
UNVISITED = [
    [0, 0, 0, 0],
    [0, 0, 0, 0]]


def test_move_is_not_visited():
    assert is_visited(UNVISITED, X, Y, ROTATION) is False


def test_move_is_visited():
    visited = [row[:] for row in UNVISITED]
    visit(visited, X, Y, ROTATION)
    assert is_visited(visited, X, Y, ROTATION) is True

We have now laid the groundwork to implement the placements function that computes all valid moves for a piece in its starting position.

We start with the test:

(ns tetrisanalyzer.piece.move.placement-test
  (:require [clojure.test :refer :all]
            [tetrisanalyzer.piece.piece :as piece]
            [tetrisanalyzer.piece.move.placement :as placement]
            [tetrisanalyzer.piece.settings.atari-arcade :as atari-arcade]))

(def start-x 2)
(def sorter (juxt second first last))

(def board [[0 0 0 0 0 0]
            [0 0 1 1 0 0]
            [0 0 1 0 0 1]
            [0 0 1 1 1 1]])

(def shapes atari-arcade/shapes)

;; Start position of the J piece:
;; --JJJ-
;; --xxJ-
;; --x--x
;; --xxxx
(deftest placements--without-rotation-kick
  (is (= [[2 0 0]
          [3 0 0]]
         (sort-by sorter (placement/placements board piece/J start-x false shapes)))))

;; With rotation kick, checking if x-1 fits:
;; -JJ---
;; -Jxx--
;; -Jx--x
;; --xxxx
(deftest placements--with-rotation-kick
  (is (= [[1 0 1]
          [2 0 0]
          [3 0 0]
          [0 1 1]]
         (sort-by sorter (placement/placements board piece/J start-x true shapes)))))

This tests that we get back the valid [x y rotation] positions where a piece can be placed on the board from its starting position.

The implementation:

(ns tetrisanalyzer.piece.move.placement
  (:require [tetrisanalyzer.piece.move.move :as move]
            [tetrisanalyzer.piece.move.visit :as visit]
            [tetrisanalyzer.board.interface :as board]
            [tetrisanalyzer.piece.bitmask :as bitmask]))

(defn ->placements [board x y p rotation bitmask valid-moves visited-moves rotation-fn shapes]
  (loop [next-moves (list [x y rotation])
         placements []
         valid-moves valid-moves
         visited-moves visited-moves]
    (if-let [[x y rotation] (first next-moves)]
      (let [next-moves (rest next-moves)]
        (if (visit/visited? visited-moves x y rotation)
          (recur next-moves placements valid-moves visited-moves)
          (let [[down placement] (move/down board x y p rotation bitmask shapes)
                moves (keep #(% board x y p rotation bitmask shapes)
                            [move/left
                             move/right
                             rotation-fn
                             (constantly down)])]
            (recur (into next-moves moves)
                   (concat placements placement)
                   (conj valid-moves [x y rotation])
                   (visit/visit visited-moves x y rotation)))))
      placements)))

(defn placements [board p x kick? shapes]
  (let [y 0
        rotation 0
        bitmask (bitmask/rotation-bitmask shapes p)
        visited-moves (board/empty-board board)
        rotation-fn (move/rotation-fn kick?)]
    (if (move/valid-move? board x y p rotation shapes)
      (->placements board x y p rotation bitmask [] visited-moves rotation-fn shapes)
      [])))

Let us walk through the following section in ->placements:

(loop [next-moves (list [x y rotation])
       placements []
       valid-moves valid-moves
       visited-moves visited-moves]

These four lines initialise the data we&aposre looping over: next-moves is the list of moves we need to process (it grows and shrinks as we go), and placements accumulates valid moves.

Since Clojure doesn’t support tail recursion, we use loop instead, to avoid stack overflow on boards larger than 10×20.

(if-let [[x y rotation] (first next-moves)]

Retrieves the next move from next-moves and continues with the code immediately after, or returns placements (the last line in the function, representing all valid moves) if next-moves is empty.

(let [next-moves (rest next-moves)]

Drops the first element from next-moves, the one we just picked.

(if (visit/visited? visited-moves x y rotation)

If we&aposve already visited this move, continue with:

(recur next-moves placements valid-moves visited-moves)

Which continues our search for valid moves (the line after (loop [...]) by moving on to the next move to evaluate.

Otherwise, if the move hasn&apost been visited, we do:

(let [[down placement] (move/down board x y p rotation bitmask shapes)
     ...]

This sets down to the next downward move (if free) or placement if we can&apost move down, which happens when we hit the bottom or when part of the "stack" is in the way.

For these lines:

(keep #(% board x y p rotation bitmask shapes)
      [move/left
       move/right
       rotation-fn
       (constantly down)])

The % gets replaced with each function in the vector, which is equivalent to:

[(move/left board x y p rotation bitmask shapes)
 (move/right board x y p rotation bitmask shapes)
 (rotation-fn board x y p rotation bitmask shapes)
 (down board x y p rotation bitmask shapes)]

These function calls generate all possible moves (including rotations), returning [x y rotation] for positions that are free on the board, or nil if occupied. The keep function filters out nil values, leaving only valid moves in moves.

Finally we execute:

(recur (into next-moves moves)
       (concat placements placement)
       (conj valid-moves [x y rotation])
       (visit/visit visited-moves x y rotation))

Which calls loop again with:

  • next-moves updated with any new moves
  • placements updated with any valid placement
  • valid-moves updated with the current move
  • visited-moves with the current move marked as visited

This keeps going until next-moves is empty, and then we return placements.

The function that kicks everything off and returns valid moves for a piece in its starting position:

(defn placements [board p x kick? shapes]
  (let [y 0
        rotation 0
        bitmask (bitmask/rotation-bitmask shapes p)
        visited-moves (board/empty-board board)
        rotation-fn (move/rotation-fn kick?)]
    (if (move/valid-move? board x y p rotation shapes)
      (->placements board x y p rotation bitmask [] visited-moves rotation-fn shapes)
      [])))
  • board: a two-dimensional vector representing the board, usually 10x20.
  • p: piece index (0, 1, 2, 3, 4, 5, or 6).
  • x: which column the 4x4 grid starts in (where the piece sits). First column is 0.
  • y: set to 0 (top row for the 4x4 grid).
  • rotation: set to 0 (starting rotation).
  • bitmask: used when iterating over rotations so that it wraps back to 0 after reaching the maximum number of rotations it can perform.
  • visited-moves: has the same structure as a board, a two-dimensional array, usually 10x20.
  • rotation-fn: returns the right rotation function depending on whether kick is enabled. Also tries position x-1 if kick? is true.
  • shapes: the shapes for all pieces and their rotation states, stored as [x y] cells.
  • (if (move/valid-move? board x y p rotation shapes): we need to check whether the initial position is free; if not, return an empty vector.
  • (->placements board x y p rotation bitmask [] visited-moves rotation-fn shapes) computes the valid moves.

Implementation in Python:

from collections import deque

from tetrisanalyzer import board as board_ifc
from tetrisanalyzer.piece import piece
from tetrisanalyzer.piece.bitmask import rotation_bitmask
from tetrisanalyzer.piece.move import move
from tetrisanalyzer.piece.move import visit

def _placements(board, x, y, p, rotation, bitmask, valid_moves, visited_moves, rotation_move_fn, shapes):
    next_moves = deque([[x, y, rotation]])
    valid_placements = []

    while next_moves:
        x, y, rotation = next_moves.popleft()

        if visit.is_visited(visited_moves, x, y, rotation):
            continue

        down_move, placement = move.down(board, x, y, p, rotation, bitmask, shapes)

        moves = [
            move.left(board, x, y, p, rotation, bitmask, shapes),
            move.right(board, x, y, p, rotation, bitmask, shapes),
            rotation_move_fn(board, x, y, p, rotation, bitmask, shapes),
            down_move]

        moves = [m for m in moves if m is not None]

        next_moves.extend(moves)

        if placement is not None:
            valid_placements.extend(placement)

        valid_moves.append([x, y, rotation])
        visit.visit(visited_moves, x, y, rotation)

    return valid_placements


def placements(board, p, start_x, kick, shapes):
    y = 0
    rotation = 0
    bitmask = rotation_bitmask(shapes, p)
    visited_moves = board_ifc.empty_board(board_ifc.width(board), board_ifc.height(board))
    rotation_move_fn = move.rotation_fn(kick)

    if not move.is_valid_move(board, start_x, y, p, rotation, shapes):
        return []

    return _placements(board, start_x, y, p, rotation, bitmask, [], visited_moves, rotation_move_fn, shapes)

The code follows the same algorithm as in Clojure. We use deque because it&aposs slightly faster than a list when performing both popleft and extend.

Testing

Finally, we run our tests:

$> cd ~/source/tetrisanalyzer/langs/clojure/tetris-polylith
$> poly test :dev
Projects to run tests from: development

Running tests for the development project using test runner: Polylith built-in clojure.test runner...
Running tests from the development project, including 2 bricks: board, piece

Testing tetrisanalyzer.board.core-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

Test results: 1 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.board.clear-rows-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

Test results: 1 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.board.grid-test

Ran 2 tests containing 2 assertions.
0 failures, 0 errors.

Test results: 2 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.shape-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

Test results: 1 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.move.placement-test

Ran 2 tests containing 2 assertions.
0 failures, 0 errors.

Test results: 2 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.move.move-test

Ran 11 tests containing 11 assertions.
0 failures, 0 errors.

Test results: 11 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.move.visit-test

Ran 2 tests containing 2 assertions.
0 failures, 0 errors.

Test results: 2 passes, 0 failures, 0 errors.

Testing tetrisanalyzer.piece.piece-test

Ran 1 tests containing 1 assertions.
0 failures, 0 errors.

Test results: 1 passes, 0 failures, 0 errors.

Execution time: 0 seconds

Python:

$> cd ~/source/tetrisanalyzer/langs/python/tetris-polylith-uv
$> uv run pytest
======================================================================================================= test session starts ========================================================================================================
platform darwin -- Python 3.13.11, pytest-9.0.2, pluggy-1.6.0
rootdir: /Users/tengstrand/source/tetrisanalyzer/langs/python/tetris-polylith-uv
configfile: pyproject.toml
collected 21 items

test/components/tetrisanalyzer/board/test_clear_rows.py .                                                                                                                                                                    [  4%]
test/components/tetrisanalyzer/board/test_core.py ..                                                                                                                                                                         [ 14%]
test/components/tetrisanalyzer/board/test_grid.py ..                                                                                                                                                                         [ 23%]
test/components/tetrisanalyzer/piece/move/test_move.py ...........                                                                                                                                                           [ 76%]
test/components/tetrisanalyzer/piece/move/test_placement.py ..                                                                                                                                                               [ 85%]
test/components/tetrisanalyzer/piece/move/test_visit.py ..                                                                                                                                                                   [ 95%]
test/components/tetrisanalyzer/piece/test_shape.py .                                                                                                                                                                         [100%]

======================================================================================================== 21 passed in 0.02s ========================================================================================================

Nice, all tests passed!

Summary

In this third post, I took on the not entirely trivial task of computing all valid moves for a piece in its starting position.

I avoided implementing it as a recursive algorithm, since that would limit how large our boards can get.

We reminded ourselves that code should live where we expect to find it.

We also took the opportunity to make the code easier to work with, by specifying pieces in a more readable way, and with that change we could easily support three different Tetris variants.

Hope you had just as much fun as I did 😃

Happy Coding!

Permalink

Babashka 1.12.215: Revenge of the TUIs

Babashka is a fast-starting native Clojure scripting runtime. It uses SCI to interpret Clojure and compiles to a native binary via GraalVM, giving you Clojure&aposs power with near-instant startup. It&aposs commonly used for shell scripting, build tooling, and small CLI applications. If you don&apost yet have bb installed, you can with brew:

brew install borkdude/brew/babashka

or bash:

bash <(curl -s https://raw.githubusercontent.com/babashka/babashka/master/install)

This release is, in my opinion, a game changer. With JLine3 bundled, you can now build full terminal user interfaces in babashka. The bb repl has been completely overhauled with multi-line editing, completions, and eldoc. deftype now supports map interfaces, making bb more compatible with existing libraries like core.cache. SCI has had many small improvements, making riddley compatible too. Riddley is used in Cloverage, a code coverage library for Clojure, which now also works with babashka (Cloverage PR pending).

Babashka conf 2026

But first, let me mention an exciting upcoming event! Babashka conf is happening again for the second time! The first time was 2023 in Berlin. This time it&aposs in Amsterdam. The Call for Proposals is open until the end of February, so there is still time to submit your talk or workshop. We are also looking for one last gold sponsor (500 euros) to cover all costs.

Highlights

JLine3 and TUI support

Babashka now bundles JLine3, a Java library for building interactive terminal applications. You get terminals, line readers with history and tab completion, styled output, keyboard bindings, and the ability to reify custom completers, parsers, and widgets — all from bb scripts.

JLine3 works on all platforms, including Windows PowerShell and cmd.exe.

Here&aposs a simple interactive prompt that reads lines from the user until EOF (Ctrl+D):

(import &apos[org.jline.terminal TerminalBuilder]
        &apos[org.jline.reader LineReaderBuilder])

(let [terminal (-> (TerminalBuilder/builder) (.build))
      reader   (-> (LineReaderBuilder/builder)
                   (.terminal terminal)
                   (.build))]
  (try
    (loop []
      (when-let [line (.readLine reader "prompt> ")]
        (println "You typed:" line)
        (recur)))
    (catch org.jline.reader.EndOfFileException _
      (println "Goodbye!"))
    (finally
      (.close terminal))))

babashka.terminal namespace

A new babashka.terminal namespace exposes a tty? function to detect whether stdin, stdout, or stderr is connected to a terminal:

(require &apos[babashka.terminal :refer [tty?]])

(when (tty? :stdout)
  (println "Interactive terminal detected, enabling colors"))

This accepts :stdin, :stdout, or :stderr as argument. It uses JLine3&aposs terminal provider under the hood.

This is useful for scripts that want to behave differently when piped vs. run interactively, for example enabling colored output or progress bars only in a terminal.

charm.clj compatibility

charm.clj is a new Clojure library for building terminal user interfaces using the Elm architecture (Model-Update-View). It provides components like spinners, text inputs, lists, paginators, and progress bars, with support for ANSI/256/true color styling and keyboard/mouse input handling.

charm.clj is now compatible with babashka (or rather, babashka is now compatible with charm.clj), enabled by the combination of JLine3 support and other interpreter improvements in this release. This means you can build rich TUI applications that start instantly as native binaries.

Here&aposs a complete counter example you can save as a single file and run with bb:

#!/usr/bin/env bb

(babashka.deps/add-deps
 &apos{:deps {io.github.TimoKramer/charm.clj {:git/sha "cf7a6c2fcfcccc44fcf04996e264183aa49a70d6"}}})

(require &apos[charm.core :as charm])

(def title-style
  (charm/style :fg charm/magenta :bold true))

(def count-style
  (charm/style :fg charm/cyan
               :padding [0 1]
               :border charm/rounded-border))

(defn update-fn [state msg]
  (cond
    (or (charm/key-match? msg "q")
        (charm/key-match? msg "ctrl+c"))
    [state charm/quit-cmd]

    (or (charm/key-match? msg "k")
        (charm/key-match? msg :up))
    [(update state :count inc) nil]

    (or (charm/key-match? msg "j")
        (charm/key-match? msg :down))
    [(update state :count dec) nil]

    :else
    [state nil]))

(defn view [state]
  (str (charm/render title-style "Counter App") "\n\n"
       (charm/render count-style (str (:count state))) "\n\n"
       "j/k or arrows to change\n"
       "q to quit"))

(charm/run {:init {:count 0}
            :update update-fn
            :view view
            :alt-screen true})
charm.clj counter example running in babashka

More examples can be found here.

Deftype with map interfaces

Until now, deftype in babashka couldn&apost implement JVM interfaces like IPersistentMap, ILookup, or Associative. This meant libraries that define custom map-like types, a very common Clojure pattern, couldn&apost work in babashka.

Starting with this release, deftype supports map interfaces. Your deftype must declare IPersistentMap to signal that you want a full map type. Other map-related interfaces like ILookup, Associative, Counted, Seqable, and Iterable are accepted freely since the underlying class already implements them.

This unlocks several libraries that were previously incompatible:

  • core.cache: all cache types (BasicCache, FIFOCache, LRUCache, TTLCache, LUCache) work unmodified
  • linked: insertion-ordered maps and sets

Riddley and Cloverage compatibility

Riddley is a Clojure library for code walking that many other libraries depend on. Previously, SCI&aposs deftype and case did not macroexpand to the same special forms as JVM Clojure, which broke riddley&aposs walker. Several changes now align SCI&aposs behavior with Clojure: deftype macroexpands to deftype*, case to case*, and macroexpand-1 now accepts an optional env map as second argument (inspired by how the CLJS analyzer API works). Together these changes enable riddley and tools built on it, like cloverage and Specter, to work with bb.

Riddley has moved to clj-commons, thanks to Zach Tellman for transferring it. I&aposd like to thank Zach for all his contributions to the Clojure community over the years. Version 0.2.2 includes bb compatibility, which was one of the first PRs merged after the transfer. Cloverage compatibility has been submitted upstream, all 75 cloverage tests pass on both JVM and babashka.

Console REPL improvements

The bb repl experience has been significantly improved with JLine3 integration. You no longer need rlwrap to get a comfortable console REPL:

  • Multi-line editing: the REPL detects incomplete forms and continues reading on the next line with a #_=> continuation prompt
  • Tab completion: Clojure-aware completions powered by SCI, including keywords (:foo, ::foo, ::alias/foo)
Tab completions in bb repl
  • Ghost text: as you type, the common completion prefix appears as faint inline text after the cursor. Press TAB to accept.
Ghost text in bb repl
  • Eldoc: automatic argument help — when your cursor is inside a function call like (map |), the arglists are displayed below the prompt
  • Doc-at-point: press Ctrl+X Ctrl+D to show full documentation for the symbol at the cursor
  • Persistent history: command history saved across sessions in ~/.bb_repl_history
  • Ctrl+C handling: first press on an empty prompt warns, second press exits

Many of these features were inspired by rebel-readline, Leiningen&aposs REPL, and Node.js&aposs REPL.

SCI improvements

Under the hood, SCI (the interpreter powering babashka) received many improvements in this cycle:

  • Functional interface adaptation for instance targets: you can now write (let [^Predicate p even?] (.test p 42)) and SCI will adapt the Clojure function to the functional interface automatically.
  • Type tag inference: SCI now infers type tags from let binding values to binding names, reducing the need for explicit type hints in interop-heavy code.
  • Several bug fixes: read with nil/false as eof-value, letfn with duplicate function names, ns-map not reflecting shadowed vars, NPE in resolve, and .method on class objects routing incorrectly.

Other improvements

  • Support multiple catch clauses in combination with ^:sci/error
  • Fix satisfies? on protocols with proxy
  • Support reify with java.time.temporal.TemporalQuery
  • Fix reify with methods returning int/short/byte/float primitives
  • nREPL server now uses non-daemon threads so the process stays alive without @(promise)
  • Add clojure.test.junit as built-in source namespace
  • Add cp437 (IBM437) charset support in native binary via selective GraalVM charset Feature, avoiding the ~5MB binary size increase from AddAllCharsets. More charsets can be added on request.

For the full list of changes including new Java classes and library bumps, see the changelog.

Thanks

Thank you to all the contributors who helped make this release possible. Special thanks to everyone who reported issues, tested pre-release builds from babashka-dev-builds, and provided feedback.

Thanks to Clojurists Together and all babashka sponsors and contributors for their ongoing support. Your sponsorship makes it possible to keep developing babashka.

And thanks to all babashka users: you make this project what it is. Happy scripting!

Permalink

Pull Playground - Interactive Pattern Learning

Context

The lasagna-pull pattern DSL is central to how we build APIs at Flybot (see Building a Pure Data API with Lasagna Pull). But learning the syntax from documentation alone is slow. You need to type patterns, see results, and build intuition through experimentation.

I built the playground as a companion to flybot.sg. The goal was a zero-setup environment where someone could open a URL and start writing patterns immediately, without cloning a repo, starting a REPL, or connecting to a database.

Two modes, one UI

The playground supports two modes, toggled via URL path (/sandbox, /remote):

Mode How it works Backend needed?
Sandbox SCI evaluates patterns in-browser against sample data No
Remote HTTP POST to a live server API (e.g. flybot.sg) Yes

The UI is mode-agnostic. Views dispatch {:pull :pattern} and the effect system routes to the right executor (see dispatch-of). Switching modes changes the transport, not the interface.

Sandbox is the default and the one most people use. It ships with progressive examples that teach the DSL step by step: binding scalars, querying collections, using :when constraints, composing across collections, and performing mutations (create, update, delete). Each example loads a pre-filled pattern into the editor. For mutations, the data panel refreshes automatically so you can see the effect.

Remote connects to a live server and sends the same Transit-encoded patterns that flybot.sg uses for its own frontend. This is useful for testing patterns against real data or debugging API behavior. Remote mode also adds schema-aware autocomplete tooltips from the server's Malli schema.

Why SCI

Pull patterns support :when constraints with predicate functions:

{:posts {{:id 1} {:title (?t :when string?)}}}

On a server, string? resolves from clojure.core. In the browser, there is no Clojure runtime. SCI (Small Clojure Interpreter) fills this gap: it provides a sandboxed Clojure evaluator in ClojureScript.

The sandbox initializes SCI with a curated whitelist of safe functions (pos?, string?, count, =, etc.) covering what people actually use in :when constraints. No eval, no IO, no side effects.

The key insight is that the same remote/execute function runs in both modes. On the server, it uses Clojure's built-in resolve. In the sandbox, it uses SCI's resolve and eval. The pull engine does not know or care which one it is talking to.

Same engine, in-memory data

The sandbox needs something that behaves like a database. The collection library provides atom-source, an in-memory implementation backed by atoms that supports the same CRUD operations as a real DataSource. The sandbox store is a map of atom-source-backed collections, initialized with sample data (users, posts, config).

Two design choices worth noting:

Schema is pull-able data. Instead of a separate endpoint, the schema lives in the store alongside domain collections. Querying {:schema ?s} returns it through the same pull mechanism as {:users ?all}. The playground uses pull for everything, including introspecting its own API.

Reset is a pull mutation. Resetting data to defaults is expressed as {:seed {nil true}}, a standard create mutation. The seed entry resets all atom-sources to their initial state. No special reset endpoint, no reload.

Deployment

The sandbox runs entirely in the browser, so the playground is a pure SPA with no server dependency. This makes hosting straightforward: an S3 bucket behind CloudFront, deployed via GitHub Actions on git tag. Total cost is under $1/month.

The deploy pipeline reuses the same CI/CD setup as flybot.sg. A bb tag examples/pull-playground 0.1.0 triggers a shadow-cljs release build, S3 sync, and CloudFront cache invalidation.

The full source is in lasagna-pattern/examples/pull-playground.

Permalink

Copyright © 2009, Planet Clojure. No rights reserved.
Planet Clojure is maintained by Baishamapayan Ghose.
Clojure and the Clojure logo are Copyright © 2008-2009, Rich Hickey.
Theme by Brajeshwar.