Ejecting Out of an ORM
One of the most significant benefits of any web framework is the data layer provided through an Object-Relational Mapper. From Django’s
models to Laravel’s
Eloquent, using an ORM is so common that most web developers do not realize they’re doing it for the first few months or years of their career. To them, the ORM and database are the same. One can even pull in an ORM piecemeal into a project and leverage its mapping functionality.
One upside to this is that data persistence and all of the complexities encompassed therein have become just another lesson in a web developer boot camp and can generally be taught alongside other vital concepts for application development. They don’t need to know the intricacies of SQL queries or even connect to their running application database, at least while they’re learning or junior engineers. Granted, they should start diving more into the data layer, but ORMs vastly simplify the on-ramp.
However, as you use an ORM, it might become apparent that some trade-offs are inherent in using one. This becomes especially pronounced as you pull apart a once monolithic web app codebase into smaller microservices. How do you share code in this case? Also, suppose you’ve used an ORM for any time. In that case, you’ll likely have cut yourself on the sharp edges before, updating a record at the wrong time or with the incorrect information, introducing slow N+1 queries into a seemingly harmless read operation. Because we’ve separated ourselves from the data layer to treat these objects as parts of our system, side effects are too easy to come across. Sometimes, you want to work with a plain old object.
Let’s examine some of these trade-offs more in-depth and see if you could benefit from ejecting out of your ORM.
When do we need to eject?
First, what do I mean by eject? Simply put, we’re going to convert from a complex, ORM-derived class to a precise data class that mainly has read-only fields. My mental image of this is someone jettisoning from a jet plane into a more manageable propeller plane, hence “eject.”
Even though we are moving to a more specific object, I don’t believe everyone should be ejecting all the time out of their ORMs. If your codebase is brand new, is monolithic, and has yet to achieve a market fit, thinking about ORM ejection should be pretty low on your list of priorities. However, I want you to understand that it’s an implicit trade-off you’re making by continuing with an ORM. This trade-off could be detrimental down the road and needs to be evaluated periodically.
Speaking of trade-offs, let’s look at a few that could encourage ejecting out of an ORM.
Possibly the most significant implied trade-off when you use a web framework is that any code you write will always run in the context of that framework. This trade-off is especially true for the ORM; it maps one data source to some application models that tend to be scattered throughout the rest of your code, violating a paradigm called Functional Code, Imperative Shell. I hope to write much more about Functional Core, Imperative Shell in the future. It also tightly couples our application logic to one data source, making it much more challenging to move models away from our monolith.
Consider the example where we have a large Rails codebase and hundreds of models mapping hundreds of tables in our PostgreSQL database. Our models are used throughout our
app (Rails framework-specific application code) and
lib (should have no Rails dependency) folder. We want to move to a few microservices consumed by the monolith but also available through a public API.
We don’t have a ton of great options here. We could stand up our new microservice, also in Rails, and copy over the model and export the associated tables over to the new database. But then we’d need to unwind all the references in the monolith; potentially, the monolith will still need direct access to that table. Further, if we intend to use the same model in both services, we’ll need to keep them in sync.
Okay, what if the microservice is connected to the same PostgreSQL instance as the monolith? Fine, but we might introduce some duplication or locking issues that our framework usually would prevent for us if running in the same process. This would also be surprising behavior for new developers on our team.
Let’s contrast this with a monolith using ORM ejection. In our case, we use
ActiveRecord models, but, anytime we use them in our library, we convert them to a simple struct data type. We then access their fields as we would if they were models. If we need to perform any other database operations, we have a light layer that transforms them back into models and persists changes.
There is a little more overhead to this work, but now we have some shareable types that we can introduce into other codebases. We could have a library of our application types shared between the monolith and our microservices. Any communication would transform HTTP responses into these types, and whichever service is responsible for the data layer could, in turn, convert them into the ORM models for persistence.
Simple Objects are Easier to Handle
As a quick demonstration, open up the console for a web application you have locally available on your computer. Grab an instance of any one of your ORM models. Then, check out all the methods you have access to because it is an ORM model. In addition to your getter and setters for the table columns and your regular
delete actions, there will likely be many other methods you’ve never considered using. This is on top of the potential association mapping that the ORM is doing for you.
This extra overhead on your model is potentially dangerous. You could be using your models for simple calculations and inadvertently persist a chance to the database. Even if you probably don’t make this mistake, a newer engineer on your team could. The model gives them a method to do so; it can be called whenever, and this is the crux of the issue. By providing us a surface for interacting with the database at any point, an ORM invites us to misuse it.
Another example revolves around relationship loading. Most ORMs lazily load an association, meaning if the
Address models won’t be populated out of the database until the code tries to access them. This is a great design decision as it prevents unnecessary data transfer and potentially saves time in the data layer. A downside of this behavior manifests itself when we know we need all of the addresses for some application logic. Most ORMs give us a way to express we want them preloaded in a query. But it’s all too easy to loop over a lazily loaded model and issue new database queries for each item.
This behavior is further exasperated if your code receives a model as an argument in some application code. Can we trust that the caller preloaded this model correctly? Should we do that? Is this field even a relationship, or is it simply a column? We have many questions when given a model, and we should be highly skeptical of the model.
What if our code was given that simple data struct type? Then there are no questions. The fields contain literal values. We can trust there are no side effects in the background, and we can operate on the type safely without being paranoid about it.
One of the key benefits of simplicity is that, by reducing the method surface area, we’ve directed anyone using the type how to use. Any misconceptions about what to do with the object should dissipate if we can only call getters and setters or straightforward calculation methods.
Separation of Data and Application Logic
If I had to give a trite explanation for an ORM, I’d say it’s a framework that prevents me from having to write SQL. While an oversimplification, this reasoning helps me consider any ORM model I’m working with as an extension of the data layer, not my application itself.
ORMs are great for querying, enforcing database constraints, abstracting which data source we’re using, and mapping relationships across multiple tables. These are all data layer concerns, and we should use our ORM to the fullest when dealing with any of these aspects of our application.
Making an application control flow decision, performing a complex calculation as a part of business logic, deciding between HTML and JSON to send back to a client, and how to render that response is all application concerns.
Most ORMs tend to blur these lines, directly defining business logic-oriented methods on the model. For simple applications, this is fine! How quickly I can stand up an app is why I choose Rails as a framework often. However, very quickly, you should start viewing this cross-pollination as detrimental to your overall application health, seek to define your business logic in a reusable, functional core, and keep your ORM squarely focused on interacting with the database.
Hopefully, I’ve convinced you that your application could benefit from ejecting out of your ORM more often. Luckily, you can get started today by introducing simple classes, writing brief conversion functions, and considering the difference between your data and business logic.
I proudly use Ruby and Rails in my day job and on most side projects. Our ORM in the Rails framework is called ActiveRecord and is a potent, sharp tool that can be misused and abused by even the most veteran developer. My recent excursion into Ruby type systems with Sorbet inspired me to start relying more on Sorbet’s
T::Struct, a more portable data class, than
ActiveRecord models. I had some reused code to convert between models and structs that I wanted to DRY up, and I put some side project code into a gem for reuse.
activerecord-ejection_seat allows you to easily specify another class that an
ActiveRecord model ejects to and gives convenient methods for converting between these classes. Check out that gem if you’re interested in this pattern in your Rails app; or if you want an example of how you can implement ejection in another ORM.