EDIT: It's now 1.5 years since I wrote this post, and I forgot to update it: 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 also resolved, as some of the pods have been running for more than a year now. Our cloud bill for the entire app is ridiculously low thanks to Crystal's performance.
It's been more than three years since I got hooked on Crystal Lang. For those who don't know, Crystal promises the ability to write high-level code with a syntax similar to Ruby while adding strongly-typed yet inferred parameters and avoiding the null exception problem. It also provides a complete and powerful standard library and uses concepts from new-generation languages like Go Lang.
Oh, and it includes a powerful compile-time macro system, making runtime reflection obsolete!
Did it fulfill its promises? Yes, but at some costs.
started developing in Crystal Lang out of curiosity and pleasure, as I thought it was a fun language to work with. Then I challenged myself to see what I could create, and it resulted in Clear, an ORM I've built from scratch.
Eventually, I started working on a booking platform. Given free rein on the technical side, I decided to design the backend in Crystal.
Nine months later, with the app finished, I can offer some insights into 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 relatively unknown and in a pre-release state.
The good
Crystal kept its promise of being a very expressive language. It allowed me to write clean, high-level business code, narrowing the gap between client requirements and code syntax. Big yes 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 regarding bookings being moved or canceled, and I was aware that two teams of developers had given up on this project before I took over.
I decided to go for a Controller - Business Logic - Model 3-tier architecture on the backend and keep the View and Presentation on the frontend (using TypeScript and MithrilJS).
Because Crystal is strongly typed, the amount of work required for spec and code coverage is significantly reduced compared to Ruby or Python.
80% of the bugs that occur in those scripting languages stem from loose parameter types or nullable types. Both cases are covered at compile time in Crystal. Essentially, I wrote only 120 test cases for this application, covering the different business cases for the user. In Ruby, for an application of this scale, I would have written around 250 to 400 tests.
At release, the application encountered very few bugs. Some edge cases we overlooked with the client failed, but overall, the compiler did an excellent job of preventing most issues.
Because the language is compiled, it is wonderful to use within a Kubernetes cluster: The app starts in less than 200ms, making horizontal scaling, health checks, and automatic restarts much more convenient than with a large Rails application.
Memory usage is relatively low, peaking at around 250MB, and I never experienced any memory leaks.
And it runs fast. Really fast. For those used to working with Rails, Django, or Laravel, it's like comparing a snail to a cheetah.
In fact, 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 maintaining a good pace and staying productive was the compilation time. It takes around 20 seconds to compile/run specs on my i7 8750H. We're talking about an app with ~9000 LOC, a few shards (libraries), for a total of probably less than 100k LOC. This compilation time is mainly due to the very dynamic nature of Crystal Lang, the work required at compile time (macros and type inference being the culprits), and the lack of incremental compilation at the time of writing this article. Twenty seconds may not seem like much, but over the course of a workday, it adds up, leading to a non-negligible loss of productivity. To be fair to the language, the problem is also on the developer's side: it's just enough time to lose focus, browse a few tabs on Reddit, check a video on YouTube, and too often, it snowballs into minutes or more. Guilty as charged!
Another challenge was the use of the young library ecosystem. The language is still relatively unknown, and some shards (the name given to libraries in the Crystal world) are not compatible with the latest version of Crystal or lack support, as some authors have either given up on the language or have little to no time to maintain them. I can't complain, as I, too, am responsible for this, having provided little maintenance over the last few months for Clear ORM.
The ugly?
During development, I encountered a few worrying bugs where the compiler literally crashed without any indication of where the problem was. It also happened that using some of the language's complex features, like inheriting from generic classes, could lead to segmentation faults during execution after a while. Issues were raised, and the Crystal Lang team fixed everything promptly.
As of now, I'm still unable to build the backend using the --release
flag due to segmentation faults at compile time. This is not a significant problem, as even without the optimization, the backend spends about 90% of its time waiting for PostgreSQL.
I still experience a random crash in the application that I haven't fixed yet. Basically, the app loses its connection to the PostgreSQL database after a while. This occurs on average between 3 to 30 days of running; I defined a /healthcheck
endpoint that throws a simple SELECT 1;
to check the connection, which is called every second by Kubernetes.
With multiple pod redundancies, and thanks to the low memory footprint and quick startup time, pods that defaulted were 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 gain by having a strict compiler that avoids hours of debugging and specs definition, you lose in compile time and 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 compile-time problems. Having a micro-service architecture is a must 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'll feel at ease with the syntax but will have to learn new design patterns or face issues with compile-time or overly complex macros/code. A common mistake is trying to use open hashes, JSON, or collections, only to realize too late that strongly typed languages don't favor open structures.
Compared to Go Lang or Rust, how does Crystal fit?
Go Lang is very dull and feels austere in my opinion. It's not a bad technology, but it feels like it lacks the magic for my personal taste. Rust is great, but it remains less elegant for writing business code. The magic Rust performs with memory management still adds a burden in terms of readability, with references, lifecycles, and other symbols, in my opinion, cluttering the essence of the code.
Knowing that the Crystal Lang team is aware of most of the problems mentioned above and is working hard to release version 1.0 of the language, I'm confident that Crystal has a bright future and will gain the traction it deserves.