TypeORM at Graphite: a technical retrospective
We're constantly iterating, learning and growing while building Graphite - that means looking back at our technical decisions and reflecting on what we could have done better. One such critical decision we took early on in our journey was the adoption of TypeORM, an Object-Relational Mapping (ORM) tool for TypeScript and JavaScript. In this post, I will share our journey with TypeORM, the challenges we encountered, and the steps we are taking to overcome them.
Why TypeORM?
We chose TypeORM due to the fact that it was the most mature TypeScript ORM at the time. These days, Prisma is a common competitor to TypeORM, but at the time we dismissed it for being too immature. TypeORM is a library that can run in NodeJS and provides a nice set of decorators which were somewhat new and interesting at the time. While these factors did influence our decision, there were more pragmatic reasons at play as well: TypeORM promised to manage the complexities of database interactions and handle the heavy lifting, allowing our small team to focus more on building out core product features. If you're interested, you can read a more in-depth comparison between Prisma and TypeORM here.
Bugs & the EntityManager dilemma
We understood that using a library means accepting that there might be some bugs, but we underestimated some of the issues we ended up encountering with TypeORM. One key problem we ran into was that in certain edge cases, TypeORM would not correctly retrieve primary keys from our PostgreSQL database. It was not a showstopper per se, more of an annoying bug that surfaced at inopportune moments, reminding us of the challenges you can face when you're working with abstractions like an ORM.
The challenges didn’t stop there. We soon found ourselves wrestling with issues related to the EntityManager API. EntityManager, in theory, provides a high-level abstraction over the database and is intended to make writing complex queries easier. However in practice, we found that it was generating suboptimal SQL.
An example of this: on TypeORM upserts, if TypeORM updated the row (rather than inserting), it would return an incorrect ID for that row (such that we would get errors using that ID). This was pretty inconvenient.
We also discovered that TypeORM's other APIs were not exactly problem-free. We were, at times, spending more time on TypeORM’s internals, trying to fix issues that were largely due to its inherent complexity and genericness.
Addressing the issue: migration to QueryBuilder
We started to wrap TypeORM's EntityManager API calls with our own wrapper, which delegates to the lower-level QueryBuilder API. Theoretically, this gives us a more flexible way to manage our queries without entirely losing the benefits of typing. But as is often the case, the migration has turned out to be a bit more complex in practice - a lot of this complexity comes down to the fact that TypeORM does a fair bit of magic under the hood and when you try to circumvent that magic, you run into unforeseen obstacles. Unpacking TypeORM's internal workings and applying them to our custom wrapper has proven to be tricky.
There's a degree of risk involved with this kind of change, which we've tried to mitigate by combing through TypeORM's open-source codebase to understand how it's performing these operations and adjusting our approaches/implementations accordingly.
Choosing an ORM: a word of advice
In retrospect, there are a few key lessons we took from our experience:
Do more thorough testing at the start: Looking back, I would have tested and compared several different ORM solutions. I would suggest running through basic scenarios, playing with different APIs, and verifying the returned data - this could have saved us from the unexpected surprises we encountered later.
Maintain flexibility: In our initial excitement with TypeORM, we dived right in and began using its APIs directly. This approach made our migration path more complicated when the cracks started to show. Keeping a certain degree of flexibility in design and implementation can save a lot of headaches down the line.
Looking Ahead: The ORM landscape at Graphite
So, where does all of this leave Graphite? What does the future hold for our relationship with TypeORM and ORMs in general?
Our pragmatic prediction is that we will continue to use TypeORM, at least for the foreseeable future. The cost and risk associated with a complete migration are high, given the amount of ORM-dependent code in our system. We're gradually migrating off the EntityManager API and onto the QueryBuilder, and we may even take it a step further and roll out our own version (oooh exciting).
An additional change we are considering involves our migration tooling. TypeORM’s auto-generated migrations work fine for small databases or low-traffic scenarios, but when it comes to zero-downtime migrations, TypeORM falls short. Finding a more robust solution for our migrations might be next on our roadmap.
Ultimately, the aim isn’t to completely discard TypeORM. Instead, we're taking a piecemeal approach to replace certain aspects of its functionality. This is a journey of continuous improvement, one that involves balancing immediate needs with long-term scalability.
Conclusion
We've had our ups and downs with TypeORM. It has served us well, particularly in the early stages of Graphite's growth, but it's also thrown a few hurdles in our path. That's part of the game when you're building a startup and choosing tools to help you grow. It's about making decisions with the best information you have at the time and learning from those choices as you move forward.
I hope our experiences can provide some insights for others navigating the ORM landscape!