3 years of Crystal Lang programming: The good, the bad, the ugly

Crystal Logo

EDIT: It's now 1.5 years since I wrote this post, and I forgot to write here: most of the problems discussed below are gone! The application is compiling and running smoothly in ---release mode. The customer is still happy, everything is working perfectly. The pod eviction problem with PostgreSQL is no more too, as some of the pods ran for more than a year now. Our cloud bill for the whole app is ridiculously low thanks to Crystal's performance.

It's been more than three years since I've been hooked into Crystal Lang. For those who don't know, Crystal promises to be able to write high-level code, borrowing syntax from Ruby while adding strongly-typed yet inferred parameters and avoiding the null exception problem. It also provides a complete and powerful stdlib and uses concepts from new generation languages like Go Lang.

Oh, and a powerful on-compile time macro system making runtime reflection obsolete!

Did it fulfill its promises? Yes. At some costs.

I started developing in Crystal Lang out of curiosity and pleasure, as I thought this is a fun language to work with. Then I challenged myself to see what I was able to write, and it ended up with Clear, an ORM I've built from scratch.

Eventually, I started working on a booking platform. Getting free hands on the technical side, I decided to design the backend in Crystal.

Nine months later and an app finished, I can give some insight about using Crystal in a professional environment; the good, the bad, and the ugly, and how to develop an app in a language that is still considered confidential and in a pre-release state.

The good

Crystal kept its promise in being a very expressive language. It allowed me to write clean high-level business code, allowing to lower the gap between clients requirement and code syntax. Big oui here!

# sleek.
post "/contact_us" do |env|
  form = ContactUsForm.from_json(env.request.body.not_nil!)
  Mail.deliver ContactUsEmail.new(form.email, form.title, form.message)
end

The customer had some very complex rules when bookings were moved or canceled, and I was aware that two teams of developers had given up on this project prior to my work.

I decided to go for a Controller - Business Logic - Model 3-tier on the backend and keep the View and Presentation on the frontend (using Typescript and MithrilJS).

Because Crystal is strongly typed, the work to be done on spec and coverage of the code is strongly reduced, in comparison to a Ruby or Python code.

80% of the bugs occurring in those script languages have their root cause laying in lose parameter type or nullable type. Both cases are covered at compile time in Crystal. Basically, I wrote only 120 test cases on this application, covering the different business cases of the user. In Ruby, for an application of this scale, I would be around 250 to 400 tests.

At release, the application faced very few bugs. Some edges case we forgot with the client were failing. But so far, the compiler did an excellent job in preventing most of the bugs.

Because the language is compiled, it is wonderful to use within a Kubernetes cluster: The app starts in less than 200ms, allowing horizontal scaling, health-check and automatic restart of the app much more convenient than a big Rails application.

Memory usage is relatively low, peeking to ~250Mb and I never experienced any memory leak whatsoever.

And it runs fast. Really fast. For those who used to work with Rails, Django, or Laravel, we are comparing snails with a cheetah there.

Actually, it runs so fast that I decided to do some complex data aggregation and transformation directly in Crystal instead of PostgreSQL.

The bad

The major challenge I faced in keeping a good pace and being productive was the compilation time. It takes around 20 seconds to compile/run spec on my i7 8750H. We are talking of an app with ~9000 LOC, few shards (library), for a grand total of probably less than 100k LOC. This compilation time is mostly due to the very dynamic nature of Crystal lang, the work needed to be done at compile-time  ( macro and type inference being the culprits ) , and the lack of incremental compilation at the time of writing this article. 20 seconds seems not much, but during a whole working day, it builds-up leading to a non-negligible loss of productivity. For the defense of the language, the problem is also at the developer level: it is just enough to lose focus, browse few tabs on Reddit, check a video on Youtube, and can - too often! -  snowball to minutes or more. Guilty I am!

Another challenge was the usage of the young library ecosystem. The language is still confidential and some shards  (the name which is given to libraries in the Crystal world) are not working with the latest version of Crystal, or lack of support, as some authors just gave up on the language or have little to no time to maintain them. I can't complain, as I am too responsible for this, as I gave little maintenance the last months over Clear ORM.

The ugly?

During development, I faced a few but worrying bugs, where the compiler was literally crashing without any idea of where the problem came from. It also happens that using some complex features of the language, like inheriting from generic classes could lead to segfault during execution after a while. Issues were raised and the Crystal Lang team fixed everything on short notice.

Since now, I'm not able to build the backend using --release flag due to segfault at compile time. This is not a problem per se, as even without the optimization, the backend spends like 90% of its time waiting for PostgreSQL.

I still get a random crash of the application I gave up fixing for now. Basically, the app can't access anymore to the PostgreSQL database after a while. This occurs on average between 3 to 30 days of running; I defined an /healthcheck endpoint throwing a simple SELECT 1; to check the connection, called each second by Kubernetes.

With multiple pods redundancy, and thanks to low memory foot-print and quick start-up time, pods defaulted got evicted and recreated in a matter of seconds.

Conclusion

If I did this app using Rails, would have I been more productive?

It's hard to say, as I spent quite a lot of time debugging, improving, and maintaining Clear ORM within the scope of this project. I think the productivity difference would have been negligible.

What you would win by having a strict compiler avoiding your hours of debugging and specs definition, you lose by the compile-time, the need to rewrite some basic functionality you could get via gems.

Would I recommend people using Crystal?

Yes, if:

  • You are aware that something might break, as the ecosystem is still young.

Avoiding monolithic application patterns will certainly reduce the compile-time problems. Having micro-service architecture is a must-have if you want to develop with Crystal, as each service will take much less time to compile, test and deploy.

The low memory footprint and fast execution time fit perfectly with this architecture. If you come from the Ruby world, you will feel at ease with the syntax but will have to learn new design patterns of face issues with compile-time or overly complex macro/code. A common mistake is to try to use open hash, JSON, or collection to realize too late that strongly typed language doesn't like open structures

Compared to Go Lang or Rust, how do Crystal fits?

Go Lang is very dull and feels austere in my opinion. It's not a bad technology, but it feels not enough magical for my personal taste. Rust is great but it remains less elegant to write business code with it. The magic Rust does with the memory still adds a burden in terms of readability, with reference, lifecycle, and other symbols polluting the essence of the code in my opinion.

Knowing that the Crystal Lang team is aware of most of the problems above and working hard to release the v1.0 of the language, I'm confident the language will know a bright future and all the traction it merits.