Clojure + NumPy Interop: The 2026 Guide to Hybrid Machine Learning Pipelines

Why choose just one – JVM stability or NumPy’s speed- when it is actually possible to have both?

Using modern interop tools such as libpython‑clj, developers can integrate Clojure’s machine-learning capabilities with Python’s extensive ecosystem without incurring unnecessary overhead. Teams can now perform numerical computing in Clojure while leveraging the full power of NumPy’s C extensions for vectorization, broadcasting, and linear algebra.

When teams directly import NumPy arrays into the Clojure workflows, they get the best of both worlds: Clojure’s functional style and concurrency, plus NumPy’s raw performance. For teams developing AI software, this just makes sense. It is a smoother path to scalable, production-ready software solutions- without having to compromise.

The Interop Landscape

libpython‑clj: The Gold Standard

If developers want Clojure and Python to work together, libpython-clj sets the bar. They get direct access to NumPy, SciPy, and Scikit‑learn without extra layers or complications. Thanks to zero-copy memory mapping, data moves between the JVM and CPython without a hitch. Developers won’t waste time converting data in both directions.

Flexiana has strong expertise in connecting Clojure and Python for machine learning, and several detailed case studies demonstrate how libpython-clj enables teams to use major tools such as NumPy, SciPy, and scikit-learn in production environments. What stands out is how these examples show that developers do not have to choose between Python’s fast research ecosystem and Clojure’s rock-solid stability—they can leverage the strengths of both. This balanced approach helps teams build scalable, production-ready software solutions for real-world projects.

tech.ml.dataset: Clojure’s Pandas Alternative

If developers want to handle data directly in Clojure, tech.ml.dataset is the go-to option. It is the closest thing to Pandas on the JVM. The best part? It plugs straight into libpython-clj, allowing data transfer between the JVM and CPython without extra copies. Teams can use Clojure to prepare and manage their datasets before sending them to NumPy for intensive computational tasks.

Pandas vs. tech.ml.dataset

FeaturePandas (Python)tech.ml.dataset (Clojure)
ColumnsFlexible column typesStrongly typed columns
IndexingLabels and multi‑indexingFunctional style indexing
Data SharingNeeds serializationZero‑copy with libpython‑clj
InteropWorks inside Python onlyConnects directly with NumPy

Neanderthal: Native Clojure Numerics

Does it not require Python? Neanderthal is a powerhouse for numerical computing in Clojure. It is fast- built on BLAS and LAPACK, and if teams want GPU action, it connects to CUDA and OpenCL. Neanderthal needs direct GPU access without Python; it runs well in the JVM.

Comparison: NumPy vs. Clojure Native Numerics

FeatureNumPy (Interop)Neanderthal (Native)
EcosystemPython ML libraries (SciPy, scikit‑learn, PyTorch)Focused on linear algebra, deep learning, and JVM tools
PerformanceVery High — native BLAS/LAPACK via C extensions Very High — native BLAS/LAPACK with JVM-native integration (no Python interop)
Ease of UseFamiliar to Python developersSteeper learning curve for Clojure developers
MemoryShared via libpython-cljNative JVM/Off‑heap
GPU SupportCuPy/PyTorch interopBuilt‑in CUDA/OpenCL
IntegrationWorks best in hybrid workflowsBest for JVM‑only projects
CommunityLarge Python community, many tutorialsSmaller but focused Clojure community
DeploymentCommon in research and prototypingStrong fit for production JVM systems
FlexibilityWide range of ML librariesSpecialized for numerics and performance

This table shows the trade‑offs clearly:

NumPy interop is a good choice if teams already work in Python and want access to its machine learning libraries. Neanderthal is better when teams need maximum speed, GPU acceleration, and want to stay fully inside the JVM.

Setting Up Clojure‑NumPy Environment

deps.edn Setup

To begin with, add libpython-clj to the deps.edn file. That is the bridge between Clojure machine learning and Python’s numerical stack.

Now, double-check where Python is installed on the system. Clojure requires a path to load libraries such as NumPy. Developers need to point to their Python interpreter or virtual environment.

REPL Integration

Once developers have configured the dependencies, they can import Python libraries directly into their REPL. 

This quick example shows how developers can create a NumPy array and run a few operations, all from Clojure. It’s proof that NumPy interop works smoothly and that AI software development gets the ideal combination: Python’s speed with Clojure’s structure.

Zero‑Copy Magic

Here’s where things get really interesting. With tech.v3.dataset, you can move data between the JVM and CPython without making extra copies. This is called zero‑copy integration. 

  • No messing around with serialization, no wasted time.
  • Just prepare your data in Clojure.
  • Transfer it to NumPy for heavy numerical processing, then continue.
With tech.v3.dataset, you can move data between the JVM and CPython without making extra copies.

This setup makes Clojure a real contender for numerical computing. Developers are not merely connecting two languages. They are building scalable software solutions that can handle complex, real-world tasks.

Building a Hybrid ML Pipeline

Step ❶: Data Preparation

Use Clojure’s sequence functions for ETL. They make cleaning and shaping data pretty effortless. 

  • Just grab the map, filter, and reduce to process raw data. 
  • When developers need proper tables, add tech.ml.dataset into the combination. 
  • Continue using Clojure for data transformations until the data is ready for heavy numerical computation.  

Step ❷: Numerical Crunching

Transfer the ready-made data to NumPy and let Python do the math.

  • Vectorization runs operations across whole arrays.
  • Broadcasting helps when arrays do not match in shape. 
  • Need matrix multiplication or decomposition? NumPy takes care of all the usual linear algebra work.  

👉 Check out the NumPy official docs for more details.

Step ❸: Model Integration

Once the data is ready, load models from Scikit‑learn or PyTorch.

  • Train or load them in Python as needed. 
  • For inference, use libpython‑clj to call Python directly from Clojure.
  • Return results to the JVM for use in production or reporting processes.

Clojure maintains deployment stability, while Python’s ML ecosystem manages the models.

Hybrid AI Pipeline

Why It Matters

The pipeline uses Clojure and Python, where they work best. Teams get: 

  • Clojure’s organized data processing.
  • Python’s high-powered numerical computing.
  • The JVM’s stability in the backend. 

Developers can scale their software while leveraging the best features of both languages.

Benefits of the “Clojure + NumPy” Approach

Benefits of the “Clojure + NumPy” Approach

✔️ REPL‑Driven Experimentation (Try Ideas Instantly)  

Clojure’s REPL makes coding fast- write, change, and run code on the spot. That loop makes it easy to test ideas. In AI software development, where teams often need to experiment extensively, that speed makes a difference. Sharing snippets and testing together keeps work moving. It is simply a smoother way to work, especially when everyone is collaborating to solve tough problems.

✔️ Functional Integrity (Stay Functional and Clean)

Python’s math libraries often change developers’ data in place, which can lead to unexpected side effects. With Clojure for machine learning, they can integrate NumPy into a functional workflow. Their data remains predictable, functions do not modify the external state, and debugging becomes less painful. They spend less time chasing weird bugs or wondering why their output changed. What is the end result for teams working on numerical computing in Clojure? Code is clean, pipelines are stable, and growth is easier.

✔️ Enterprise Scaling with Clojure Concurrency (Scale Up Without Slowing Down)

On the JVM, Clojure manages heavy workloads with real concurrency. Combine with NumPy interop to speed up numerical computations, and teams get an environment that can handle huge datasets without slowing down. Flexiana has seen real drops in latency when they combine JVM concurrency with NumPy’s speed in their ML pipelines. It is not just about raw speed- this setup lets teams scale up confidently, with the assurance their system won’t fail when the load grows.

✔️ Balanced Strengths (Best of Both Worlds)

Clojure handles concurrency and orchestration. It also handles enterprise tasks. NumPy manages the math work. Together, they produce an accurate and efficient pipeline. Developers get both performance and stability, so they don’t have to choose. If the team needs to manage distributed workloads while handling heavy numeric processing, this approach works well. Tools like libpython-clj1 tie everything together, making integration feel seamless. It is a solid way to build hybrid systems that actually last.

Common Pitfalls and How to Avoid Them

Common Pitfalls and How to Avoid Them

Mismanaging Memory Between JVM and Python

Data coordination between Clojure and Python is challenging. If teams are not paying attention, they will end up copying large datasets multiple times, wasting memory and slowing everything down.

How to avoid it: Go for zero-copy integration whenever possible, using tools like libpython-clj1 or tech.ml.dataset. Do as much as teams can in Clojure, and only bring in NumPy when it is really needed for that speed. Always monitor memory usage when dealing with large arrays.

Overusing Interop Calls (performance hit)

Interop is great, but there is an overhead involved. When developers repeatedly call Python functions from Clojure in a tight loop- thousands of times- performance drops drastically.

How to avoid it: Batch the work. Push large chunks of data to NumPy and allow it to process the calculations. Keep the control flow in Clojure and cut down on all those frequent back-and-forth calls.

Ignoring Concurrency Design

Clojure runs on the JVM, which indicates it is built for concurrency. But if teams forget to design for it, workloads jam up. Python’s GIL limits running things in parallel on the Python side.

How to avoid it: To handle concurrent processes, rely on Clojure’s concurrency tools- atoms, refs, agents, and futures. Let Python focus on numerical computation, while Clojure runs the show and scales things up. It helps to avoid running into the GIL’s roadblocks.

Not Testing Startup/Deployment Properly

Interop setups tend to fail when a developer moves from the laptops to production- wrong paths, missing dependencies, corrupted environments. On-site equipment might suddenly fail at other locations.

How to avoid it: Test the startup scripts. Ensure the Python interpreter is configured correctly, with automated CI/CD checks to quickly identify issues.

Driving Results Through Engineering Excellence

Engineering Productivity

Speed of Development  

Clojure brings everything together smoothly. It consolidates the entire pipeline into one place without creating confusion. Developers can connect Python libraries, JVM tools, and their own logic pretty fast- no mountains of boilerplate, just straight to the real problems. And with the REPL, Developers are not stuck waiting for long builds. Quick adjustments and tests keep projects on track.

Maintainability  

Clojure sticks to a functional style, so the code stays clean, and the data flows in a way that actually makes sense. Side effects remain under control. When your pipeline gets bigger, you spot bugs early, and resolving them doesn’t become a hassle. New people can jump in and understand what’s happening without getting confused, which makes onboarding much easier. Bottom line: fewer nasty surprises, easier upkeep.

Long‑Term Stability  

The JVM has been around forever, and people trust it. Years of tweaking, monitoring, and deploying mean it just works. NumPy runs efficiently, so systems scale up and handle heavy loads with ease. It remains fast and stable as workloads increase.

Team Collaboration  

The REPL makes small changes easy to test. Fast result sharing keeps everyone in sync. Teams scale with ease thanks to clear feedback that shows changes.

Integration Flexibility  

Clojure connects easily to Python, Java, and JVM tools. It plays nicely with the enterprise tools that teams already have, and they still get access to Python’s whole ML world. Teams arenot required to choose sides- they can use what works best from both. They get the freedom to bring in new tools without breaking what is already working.

❓ Quick Answers to Common Questions

Performance and Reliability

Q1: Does libpython‑clj make code slow?  

Not really. The primary slowdown stems from repeatedly switching between Clojure and Python. For heavy numerical stuff, that extra cost barely matters compared to how fast NumPy runs.

Q2: Can I use this in production?  

Absolutely. Real-world teams rely on it for JVM reliability and Python’s ML strength. Test startup and deployment the same way you test other tools.

Q3: How is memory usage?  

Both the JVM and Python consume resources. Pay attention to memory, especially if you’re working with huge datasets.

Q4: Is libpython-clj still maintained?

Indeed. The Clojure community ensures compatibility with the latest versions of Python and keeps it up to date.

Concurrency and Scaling

Q1: What about the Python GIL?  

Python code still runs under the Global Interpreter Lock. Clojure handles concurrency separately, so workloads scale, whereas Python handles concurrency internally.

Q2: Does it support parallel workloads?  

Yes. Clojure gives you concurrency tools like atoms, refs, agents, and futures, all running on the JVM, which is built for scale. Python handles numerical processing.

Integration and Flexibility

Q1: Does it support GPU acceleration?  

While Python libraries such as TensorFlow, PyTorch, and CuPy support GPU acceleration, libpython-clj itself is only an interop layer and neither enables nor restricts GPU usage.

Q2: Is it compatible with virtual environments?  

Yes. Set libpython-clj to your Python virtual environment to keep dependencies simple.

Q3: Can I mix and match multiple Python libraries?  

Yes. Import and use any Python library you want, just like you would in Python. Clojure ties everything together.

Developer Experience

Q1: How difficult is debugging?  

Quite simple. Errors show up directly in Clojure, and the REPL makes it easy to try code in small steps.

Q2:Does the REPL help collaboration?  

Definitely. The REPL makes quick tests easy, and results are simple to share.

Shaping the Future of Hybrid ML

Trends in Hybrid ML Pipelines.

Hybrid ML pipelines are gaining popularity quickly. Teams want the dependable stability from the JVM, but they are not willing to give up Python’s powerhouse ML libraries. So, rather than choosing one, an increasing number of projects use both. Combining them makes it way easier to scale up, keep things running smoothly, and adjust quickly as the workload changes.

Growing Role of Interop Tools Like libpython‑clj1.

Interop tools like libpython-clj1 are no longer just for experimentation. These days, libpython-clj1 is the go-to for integrating Clojure and Python in real production code. Developers can import NumPy, SciPy, or Scikit-learn right from Clojure- no awkward workarounds. As more teams join, tools like this are becoming the backbone of hybrid pipelines.

Potential Improvements in Zero‑Copy Integration.

Zero-copy integration is already a game-changer. Eliminating data duplication saves time and memory. Looking ahead, there is room to further improve it. Think faster pipelines, better support for huge datasets, smoother GPU acceleration, and handling complicated data structures without the usual headaches. All this will further reduce overhead and make everything feel almost effortless.

Where Flexiana Sees Hybrid ML Heading in 2026 and Beyond.

At Flexiana, we have seen hybrid ML pipelines move out of the “experimental” corner and take center stage for big companies. Here is where things are headed by 2026 and beyond:

  • Teams will see hybrid ML everywhere- finance, healthcare, retail, and more.
  • Cloud-native integration will become more integrated.
  • Teams will prioritize maintainability and stability over short-term speed.
  • Hybrid setups will be the default, not just a backup plan.

The direction is clear: Hybrid ML pipelines are not a passing trend. They are the new normal, enabling developers to leverage the best tools from both worlds and get things done.

Final Thoughts 

Clojure machine learning brings the rock-solid reliability of the JVM, while NumPy offers that raw speed Python’s known for to process numerical data. Combine them, and developers get a hybrid ML pipeline that does not force them to choose between stability and performance. With tools like libpython-clj1, moving data between the two just works. Team can access enterprise-level concurrency and fast numerical work at the same time- no compromises, especially if teams are pushing the limits of numerical computing in Clojure.

Bringing these strengths together enables teams to move faster in AI software development, test new ideas without getting stuck, and keep their codebase clean and scalable as their needs grow. It is a practical setup- flexible, efficient, and ready to handle whatever real-world demands come their way.

If you are ready to kick off your own hybrid ML pipeline, Flexiana can help you blend JVM reliability with NumPy speed. Let’s get started.

The post Clojure + NumPy Interop: The 2026 Guide to Hybrid Machine Learning Pipelines appeared first on Flexiana.

Permalink

Why Gaiwan Loves the Predictive Power of Universal Conventions


A one-stop-shop for your entire dev environment: bin/launchpad

Why Gaiwan Loves the Predictive Power of Universal Conventions

Close to four years ago we released Launchpad, a tool which has been indispensible in our daily dev workflow ever since. In the latest Clojure Survey, over 85% of respondents indicated "The REPL / Interactive Development" is important for them. We already explained at length in our Why Clojure what exactly we mean when we say "Interactive Development", and why it is so important.

In order to do Interactive Development, you need a live process to interact with, connected to your editor or IDE. This is where Launchpad comes in. It focuses on doing one thing well: starting that Clojure process for you to interact with, with all the bits and bobs that make for a pleasant and productive environment. It&aposs a simple idea, and we&aposve heard from numerous people how it has made their life easier. But not everyone seems to get it.

Ovi Stoica has been doing great work on ShipClojure, and as part of that work he created leinpad, a Launchpad for Leiningen users. It&aposs been very validating to see people pick up these same ideas and run with them. Launchpad is for Clojure CLI only, we switched away from Leiningen as soon as the official CLI came out in 2018 and never looked back. Leiningen vs Clojure CLI is perhaps a topic we can dig into in another newsletter, but needless to say it&aposs good to see a Launchpad alternative for people still using Leiningen, by need or by choice.

The Leinpad release announcement sparked some interesting discussion on Clojurians Slack, going back to some of the Launchpad design decisions. Launchpad strongly recommends that people create a bin/launchpad script, just like we also recommend that people create a bin/kaocha script to invoke the Kaocha test runner. In both cases there are two related reasons why we feel strongly about this. We want both of these to become universal conventions, so that when you start working on a Clojure project for the first time, you can safely assume that bin/kaocha runs your tests, and bin/launchpad starts your development process. In any project you can put something in that location that does that job, regardless of your stack and setup. It&aposs a form of late binding, and it means a new hire doesn&apost need to pore over the README, or worse, ask around, to know how they&aposre supposed to run the tests or run a dev environment.

Each time people instead decide they prefer bb run launchpad, or clj -X:kaocha, or any other variant, they break the predictive power of that convention. They muddy the water for everyone. This is why we resisted a -X style entrypoint for Kaocha, despite later accepting a community contribution that implements one. A convention is only as powerful as its adoption rate.

Besides being a convention, it&aposs also a place where you can customize the Launchpad (or Kaocha) behavior. For Launchpad this is especially important because the goal for the bin/launchpad script is to be a one-stop-shop for your entire dev environment. That can mean installing npm packages, running docker containers, loading stub data, anything you need so that a new contributor can arrive, run bin/launchpad, and be productive.

Recently a team member had trouble running Launchpad because their babashka version was out of date. They reached for the tool they usually use when confronted with "works on my machine" issues: Nix. Nix ensures a reproducible environment, where everyone is using the exact same versions and packages. It&aposs like tools.deps but for system software, and solves some of the issues around Phantom Dependencies.

This meant replacing the bb shebang with nix-shell. This shows the power of a filesystem convention like bin/launchpad. The facade stays the same, but what&aposs under the covers is now radically different.

-#!/usr/bin/env bb
+#!/usr/bin/env nix-shell
+#! nix-shell -i bb -p babashka
+#! nix-shell -I nixpkgs=https://github.com/NixOS/nixpkgs/archive/refs/tags/25.11.tar.gz

Later on I introduced GStreamer to the project, to support multimedia playback. This too is (partially) a system dependency, and some people on the team struggled to get it working. In this case I was able to build on the nix-shell approach, adding the additional dependencies. So instead of adding a section to the README that explains how to either brew or apt install gstreamer, I added the necessary bits to our one-stop-shop. And all the while it&aposs still just bin/launchpad. I&aposd love to see a cultural shift where we no longer accept ten steps of outdated instructions in a README just to get a dev environment. Dev tooling is part of our job, and we do ourselves, our team members, and our employers an injustice by treating it with any less care than we give to the customer facing bits of our software.

#tea-break

At Gaiwan we share interesting reads and resources in our #tea-break channel.

EU Tech Map and European Alternatives: We care about EU&aposs software rebellion to move away from American companies and doing things in-house, supporting OSS, etc. It also sets a benchmark and a path for other countries to follow.

The job losses are real — but the AI excuse is fake by David Gerard. Layoffs are done for economic reasons, but blamed on AI because that sounds better, and meanwhile the whole situation is used to get people to accept lower job offers. What&aposs your experience with this?

An AI Agent Published a Hit Piece on Me by Scott Shambaugh. In case you haven&apost seen it already, this is 🤯

One or more of us is considering this really cool open source Linux device, by an Indian team. And not as expensive as you might have thought. (We are not compensated for writing about this Kickstarter project; we really just think it&aposs cool.) So many devices, so little time...

Permalink

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

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.