Assorted Clojure web app notes
2025-11-30
I've been working almost exclusively in Clojure over the past five or six months as a learning exercise. This is a collection of my notes on what I've learned so far. Most of these notes are validations of what Rich Hickey, Clojure's creator, mentions in his talks.
General notes
- Different types of changes have different blast radii. Changes to the application concept, due to misconceptions about the domain or changes in fundamental assumptions, have the biggest and worst blast radius and very often require changes to everything that follows. Changes to the application database's schema, which should be a reflection of the conception the software developers have of the domain, have a bad blast radius that is significantly more manageable than the blast radius of a misconception.
- Your system better have articulation points. One of Clojure's many superpowers is
- Codebase problems are solved most effectively if the amount of code relevant to the problem can fit in your personal working memory. There is a notable step-function decrease in your effectiveness as a programmer if the problem you are working on is even slightly too large for your working memory.
- Keeping logic in pure transformations of data is a particularly effective way of containing the blast radius of any type of change, as purity reduces the number of dimensions in which you need to work and thus increases the effectiveness of your personal working memory.
- Programmers use the term "abstraction" often to mean the extraction of logic-performing code into distinct functions. I believe this concept is better served by the term "factorization."
- Factorization only helps if the extracted logic is truly separate from the whole. A particularly reliable way to achieve this characteristic is to factorize pure data transformations.
- False factorizations greatly increase the cost of loading code into your personal working memory.
- Purity enhances testability, not only in automated testing but in REPL testing. Purity also enhances observability in all senses.
- In my experience, using the options map for arguments that are not clearly and obviously core to the function is a particularly effective way of decomplecting changes to the function from its callsites. Interestingly, this may also be a negative characteristic if you want to strictly require changes to callsites upon changing a function.
- Greppability is an underrated characteristic of a codebase. This is why I now retrieve my configuration data with a dedicated
config-valfunction instead of using the configuration map atom directly, as there are many ways then of getting a value (e.g.,get-in, threading). - Git commits are effective if treated as the thread of Ariadne. I have a greatly increased respect for the practices of writing meaningful commit messages and of adding tags.
- Please read the Git book. It is short and comprehensible and will pay dividends forever.
- It seems to be best to keep feature branches from growing too large. Merge them back to main at sensible, short intervals. If you can't easily see a conceptual gap between main and HEAD and instead think of HEAD as its own codebase, you're too far from main.
- Re-normalization of SQL rows, initially de-normalized by joins, into nested maps for the purposes of transmission to the frontend is an interestingly common scenario.
- The power of raw SQL is still unmatched. I tried HoneySQL for a while. While I can see value in HoneySQL, especially around constructing dynamic queries, I found myself turning to raw SQL for most of the non-trivial queries I needed to do.
- I finally learned enough shortcuts on VS Code to be able to use only the keyboard for 90% of my common tasks. It turns out that this wasn't hard. This has significantly reduced my desire to switch to another editor.
- The
cljwebappframework is beginning to take shape. Requests go down the main axis of routing -> handlers -> domain functions. All other code is orthogonal to the main axis and is pulled in by the axis as necessary. Domain functions should, as much as possible, rely only onctxand notreqto make testing as easy (asctxis just data, butreqmay have objects). - Routing and middleware will affect the contents of requests going downstream. If you aren't careful, the routing module will become complected with the rest of the app, and it will be very difficult to change later.
- As of 20260114,
cljwebapphas gone through a number of "minor" internal architecture changes that reduce the overall consistency of my Litechat and SimGen codebases. Though the main axis remains stable, the approaches I take within each module are significantly less stable. (Do we store DB stuff in thequeriesandcommandsmodules or in the domain files? Do we use Malli at all? Have we completed the Reitit migration yet?) I'm not sure I can do much about this right now, since Litechat and SimGen are effectively technical exercises for me to find the most fitting architecture.
How to change software
- When you're conceptualizing a new feature, get yourself a sandbox and play with the various concepts involved. If external software is involved, try to get the intent behind the feature/bugfix to work with the native tools. This step is mandatory. Gather as much data as you can about the behavior of the space you'll be walking into.
- When revisiting an old project, or even a project that you just haven't touched for a few days, resist the urge to preemptively refactor the architecture to fit your latest tastes. Such refactors must earn their right to be implemented.
- When dealing with a misconception (e.g., moving SimGen from a chat interface to a vibecoding interface), it helps to just leave the old database tables behind and implement the new version of the app from "scratch" with new tables. Avoid modifying/deleting old data and schemas if possible, at least during the migration. You can delete them later once the new version has stabilized. This is known as an "expand-contract" approach.
- So far, in Clojure, the only mistakes that have caused me to come to a complete stop are misconceptions in the data model. I handle everything else easily, except maybe things involving streaming, because those are effectful.
Things I thought were good ideas and were
- I made a decision early on to have a
configmap atom that was loaded in from thejava -jarcommand as a CLI arg for an EDN file. I have never questioned this decision except when I had to use environment variables for compliance. - I realized early on that I was basically making my own web framework. I keep and update a Git repo of that web framework, which I call
cljwebapp. This has been one of the greatest software investments I've ever made. - To get config vals, I use a
config-valfunc. This is just a wrapper around(get-in @config (vec args)), but this makes sites that use config values very easily greppable. - I migrated the
cljwebappframework to use Aleph + Reitit + Manifold + Malli + Muuntaja. The old version used http-kit and Compojure, and it honestly worked perfectly fine. I'm mostly interested in the interface documentation and the closer alignment to "just make everything data" approach. But this new version makes the shape of the data accepted at each point very explicit, which is a great help when you need to revisit a module you haven't seen in a while.
Things I thought were good ideas but weren't
- In the beginning, I forced all database queries to go through a function in either the
queries.cljorcommands.cljfiles. I then added a directdb/exec!function that would accept either next.jdbc query vecs or HoneySQL maps. This introduced more than one way to do things, which meant there was more than one thing to grep for. - I once tried to bring the Clojure paradigm of denoting boolean values with question marks (e.g.,
admin?) to the frontend via JSON that I would ship from the backend. This was a bad idea because idiomatic JavaScript appears to favor direct property accessors on JS objects, which won't allow question marks. This causes significant friction when coding the frontend using LLMs.
Things I think are good ideas that I haven't tried
Things I think are good ideas that are too fresh to judge
Justified tech debt
- Several of my projects (Litechat, SimGen) had a need to manage files. I originally stored some of these files in the database, since I anticipated that they would only be text files. This eventually came back to haunt me as the use cases evolved to require binary files, so I had to migrate file storage to URI-based storage. However, keeping the initial use cases narrow let me deploy faster, so I think this was justified, if annoying.
- For both Litechat and SimGen, I did not initially try to design a common interface for different models. Litechat's sessions each have a dedicated endpoint per different model/provider, and SimGen's reliance on Gemini was hardcoded into it. This is biting me now, but again, I would not have been able to deploy had I preemptively come up with that common interface.
Large language models
- I usually use Gemini 2.5 Pro for my frontend needs. The
cljwebappframework is built with that paradigm in mind: build a comprehensible JSON/multipart API and let Gemini figure out how to call it. - Gemini 3 Pro is good, but at the moment (20251212) a little unreliable compared to 2.5, and not that much better for my needs.
- Devstral 2 by Mistral was released a few days ago. I added it to Litechat. It can pretty competently handle smaller changes, and it's a fifth of the price of Gemini 2.5 Pro. What really excites me about it is that it's an open model, so it gives me options to not have to rely on Google.
- LLM frontend development seems to struggle for very dynamic/unconventional UI features. It's otherwise excellent at conventional features.
- Devstral struggled a bit adding a footer. Gemini (3 Pro) of course did fine. Not sure why, but my theory is that Devstral doesn't have a thinking step before the output step. I haven't tried inducing thinking in non-thinking models manually yet, so that's something I'll have to try.
- Gemini 3 Flash is quite good and only 50% more expensive than Devstral 2. The launch comments suggested it was even better than 2.5 Pro, which does seem to be the case based on how I've been using it so far, at least with a
Highthinking level. - I find myself both consciously and subconsciously limiting the money I spend on AI. My daily drivers as of 20260114 are now DeepSeek v3.2 and Claude 4.5 Haiku. Both are cheap, but DeepSeek is really cheap. I find myself going to it for syntax reminders and easy changes that would be laborious to type out by hand. DeepSeek does make mistakes and sometimes fails to evaluate tasks at anything more than a junior level, so if I want something done right the first time, which usually means a frontend change that requires some level of creativity, I go to Haiku. I use both of these models from Litechat, because I'm trying to avoid letting AI take on anything more than an almost advisory role lest I lose control over the concept of my system.
- Asking LLMs for worldly facts, including the existence of functions in software modules, is still a crapshoot without grounding, web-based or otherwise.