Thanks a lot for the book recommendation. I bought Clojure Applied today to read the chapters “Creating Components”, “Compose Your Application” and a few more.
I used core.async
a lot, and I really like the concept. A few times we caused downtimes on our production system due to overlooking synchrounous/blocking calls in a go block, even found such an issue in the Datomic Client library. If enough requests hits your server it starts to cause a global dead lock, since the complete thread pool of core.async
is saturated with those blocking operations. The book states on page 102:
Threads are scarce and expensive resources. They consume stack space and other resources, and they’re comparatively slow to start. When these threads block for I/O, we waste those system resources.
While this is true for platform threads its not for Java’s new virtual threads. Nowadays we prefer virtual threads over go blocks, since the former can free an underlying platform thread when a blocking operation was invoked in a virtual thread. And it can warn you about virtual thread pinning if an library for example was not yet prepared for virtual threads.
I also used event sourcing multiple times in the past but somehow it always ended in a mess Especially, it is less forgiving regarding domain modeling mistakes. In a database you might just migrate the current state to one that is compatible with your code. With event sourcing you either need to modify your “immutable” events or your code always needs to know how to handle legacy events in the case you like to replay the events to calculate the current state of an aggregate. We prefer Datomic since it kind of provide the best of both worlds.
The biggest leap forward for me in the recent years was this talk by David Nolen:
Here a mini example of mine how one might split the interaction with an API in many small steps. While the world library is in part already obsolete in regard what we use for our SaaS, its Readme still describes my main modification to David’s approach. Each of our step functions always takes a map as input and returns it with additional entries. Subsequent functions should not modify existing map entries to avoid of creating similar downsides like global state. Many people might think keeping the intermediate results is a waste of memory, but for us its super valuable to log this complete data (as nippy files) in the case of an exception. If you can take a look at the intermediate results it becomes less challenging to understand and fix a bug that happened on production. This week I tried to solve a bug where we not yet capture the intermediate results. Therefore I didn’t had the data returned by the third party API. To get this data I needed to carefully assemble many things via a production REPL, which took quite some time and was a bit dangerous. But in other cases you might never again have the chance to observe the relevant data, then you add a few more log statements and hope that next time the bug occurs you have captured all relevant data. For that reason we just try to capture all the data with the described approach. For people who lean into typed programming languages it might feel uncomfortable if your function get passed a gigantic map. But for Clojure I think its a superpower, especially if you have tools like portal to conveniently inspect larger data structures. And of course in Clojure data is king, so that you can also use the REPL to inspect bigger maps with ease.
We also use step functions to assemble our system like ring handlers, routes, etc.
However, while you can move all pure steps into a prepare phase, I still not found a good way to make the overall system less imperative.