At Aircall, we are dealing with a distributed Rails Legacy monolith. We are now splitting it progressively into lightweight Ruby-Sinatra microservices, and in order not to reproduce errors from the past, we started to implement new Architecture philosophies like Clean Architecture and Domain Driven Design. ROM was a really good match to us, as it fits really well in those philosophies.
In order to efficiently present ROM’s perks, we’ll see first what problems ActiveRecord brings, then what ROM is and its concepts and finally we’ll raise some attention points about this tool.
When you are heading towards a better separation of concerns, you want to design your business logic without thinking first about which database you should use or how to persist data in an efficient way. When you’re designing an Application, you don’t know all your use cases right away, and your use cases should actually determine which kind of Datastore will be the most efficient.
ActiveRecord is easy to use when you’re bootstrapping an application, because you don’t have to enquire about how to build your persistence layer in an isolated and relevant way, everything (or almost everything) is done magically under the hood. But this shortcut becomes a nightmare when you’re scaling your application (when teams scale, in size and quantity or when your Application does more than just CRUD…) because this is when you end up complexifying your persistence layer with other technologies. This is also when you realize that your Models are fat and do a lot of different things, but it’s usually too late to split them following a proper Separation of Concerns, as it may be very expensive at this stage.
To me, one of the main problems with ActiveRecord (which is also its strength), is that it calls too many things “Model”. If you want to:
- Write data (eventually with complex queries)
- Fetch data in a particular way (with complex conditions for instance)
- Build new attributes based on one that is already in database…
.. then there is a high probability that those will end up in your Models. This is one of the main perks of ROM: it doesn’t give the same name for each of those things.
If you want to know more about ROM, you can take a look here: https://rom-rb.org/5.0/learn/ . In this chapter, we’ll look at some core concepts and how they interact with each other.
ROM uses several concepts that could be represented this way*:
*Official schema and concepts definition: ROM core concepts
The Business layer is every piece of code that handles your business logic. It could be Controllers, Workers, Scripts, or even a CLI:
- It shouldn’t know anything about your Persistence Layer: which Datastore(s) you use and how, how data is stored, associated, fetched, written, etc…
- It should use only Repositories to get and manipulate data.
- By default, it will get back
ROM::Structsfrom repositories, which is how ROM exposes raw data. As you can’t add business specific methods to those without monkey patching, it’s usually better to map them into custom Business Entities, which can hold all your business specific methods.
Entities reflect your data in a meaningful way for your Business Layer:
- They are Mutable, but aren’t linked to the Datastore.
- Unlike ActiveRecord Models, they can’t trigger data modification (you have to use Repositories which themself would use Commands for that).
- They can’t trigger any kind of read (like association fetching for instance).
- They may expose data differently compared to how they can be seen on your datastore (for instance, in a MySQL database, you could have a column that stores stringified JSON, but on your entity you would just use a normal Hash). It can even be a composition of data that can be found on different Datastores.
- They can be aggregates of multiple relations under the hood
- They expose methods that are meaningful to your Business (like a
full_nameattribute that is actually a concatenation of
last_namecolumns in your Datastore).
The main purpose of entities is to manipulate business objects in a developer friendly way; as they are not linked to your datastore, they can’t trigger any read or write actions (like callbacks or lazy loading would).
Repositories are the interface between your Business layer and your Persistence layer:
- Usually, one repository manipulates one type of Entity
- They always have at least one Relation (For instance,
Repositories::Usersinstance will have a
Relation::Usersinstance that can be accessed through
usersbuilt in method in the Repository).
- They shouldn’t know anything about your Datastore (eg. you could be fetching data on an external service or using file storage, the repository should work the same way).
- They use Relations’ methods to filter, associate and aggregate data, but they don’t know how it is done under the hood (whether filtering is made with a
whereor something else…)
- They know how to associate data in a meaningful way (for instance that it makes sense to associate object X and object Y), but not how it’s done under the hood (whether association is made with a foreign key or a join table for instance).
- By default they return
ROM::Structsbut if your Business layer uses custom Entities, then it’s Repositories’ role to build properly and return the expected Entity.
Relations can be seen as partial views on your data, whether it is a SQL Table, a particular CRUD resource on an HTTP backend or some JSON file on your file storage system.
- They hide your Datastore’s (or to be more accurate: Datasets) specific methods (like where, selects etc… if you use SQL) and expose methods that use them in order to have a higher level of abstraction.
- They contain your table’s schema, which can be specified or infered; schema contains a list of attributes that can be found on your datastore and also associations.
- They are only useful to read data.
If you use Relations properly, you shouldn’t see any more of Datastore’s specific methods (like SQL functions for instance) on your Repositories and Business layer, which means it will be way easier to change your datastore, because you would only need to change your Relations’ methods implementation.
Commands are used to modify data on your Datastore, they rely on Relations to apply meaningful transformations on it.
Datasets are basically objects that hold data and that implement your Datastore methods, they are manipulated by Relations.
Datastore is your storage system, it can be anything like a SQL Database, a HTTP backend, or just a simple File System.
All those concepts are consistent together, and once you’re used to them, it’s really clear where to add something.
If you’re an experienced Ruby developer, learning ROM may represent a huge relief to you as it will solve some of the problems your usual ORMs have and will show you another way to organise your code. But as a junior developer, this may look more like a pain point, and this is why:
- The community is pretty reduced, so no StackOverflow, so, if you want a real human’s help, your best chances will be either the forum or the chat.
- The documentation is in Specs and in the code, which is quite unusual when you’re used to finding loads of examples on the internet. You’ll sometimes need to understand the code if you want to use it properly, which is a good thing, but it takes some time and personal investment, especially if you are junior.
So, was it difficult for us to get into ROM? Yes. Do we regret using it now? Not at all! And this is because we now have the feeling that our code is much cleaner than before. Also, it pushed us to ask ourselves some questions about Architecture (and to go beyond with Clean Architecture and Hexagonal Architecture).
If you want to start to learn ROM, I recommend you to start here.
Also, if you want more concrete examples of some of these concepts, you can take a look at these slides.
If you want to talk about this subject, you can join us on this reddit thread.
Special thanks to Zayne, Florian, Baptiste and Massimo for their feedbacks!