Getting started with rom-rb
Because of the ubiquity of the Ruby on Rails web framework, the Rails ActiveRecord library has become the de-facto standard for managing database persistence in Ruby applications. But ActiveRecord—both the library and the pattern it embodies—is not a one-size-fits-all solution. When Martin Fowler first defined the Active Record pattern, he noted that it only works well:
” …if the Active Record objects correspond directly to the database tables… if your business logic is complex, you’ll soon want to use your objects direct relationships, collections, inheritance, and so forth. These don’t map easily to Active Record, and adding them piecemeal gets very messy.”
Most Ruby developers I know have a story or two about business logic in Active Record objects getting very messy indeed. But what is the alternative?
The ROM library is one option. This library seeks to cleanly separate various aspects of database persistence into small, focused objects. Today, guest chef Tim Riley joins us for the first in a three-part exploration of ROM. In today’s episode, you’ll get a first look at what makes ROM’s approach unique. Enjoy!
Video transcript & code
If you’ve built applications in Ruby...
...you’ve likely worked with databases. They enable so much for us.
But too often they can also cause us problems. The way we work with the database can end up constraining our applications in unwanted ways.
We can end up with too many details of the database spread throughout our domain logic.
Or we can end up with an application coupled too tightly to our database’s structure, simply because that's what our tools made it easiest to do.
These things all make our applications harder to maintain.
But what if we could find a toolkit that enabled powerful integration with a database, while also encouraging cleaner application design?
That toolkit is rom-rb, and we'll be spending 3 episodes together exploring how it works, and how it helps improve our application architecture.
If you've mostly worked with Rails, some of this may feel foreign to you, but stick with it, and you might just see your work with the database in a new light.
OK! Let's get started!
One of the defining patterns within rom is the repository pattern.
ROM::Repository
rom repositories serve as the key interface betwen our application's domain layer -- where our core business logic resides -- and the persistence layer -- where we care about the details of interacting with the the database.
So if we were building a blog, we would want an ArticleRepo
to offer access to our articles.
class ArticleRepo < ROM::Repository
end
Let's say we wanted to show a list of most recent articles on the home page. For this, we can add a #latest
method:
class ArticleRepo < ROM::Repository
def latest
end
end
To be able to write this method, we first need to move away from our repository over to a relation class.
Relations contain the low-level query logic for each data source.
In this case, we want an Articles
relation...
module Relations
class Articles < ROM::Relation[:sql]
end
end
... corresponding to the articles
table in our SQL database.
Here was also tell rom to infer this relation's attributes by reading the table's columns.
module Relations
class Articles < ROM::Relation[:sql]
schema :articles, infer: true
end
end
Now we can build an #ordered_by_recency
method to give us our articles in the right order.
module Relations
class Articles < ROM::Relation[:sql]
schema :articles, infer: true
def ordered_by_recency
end
end
end
For SQL relations like this one, rom gives us a powerful API for querying the database. We can use it to achieve our ordering here.
We want to order by publishing date and time, descending:
module Relations
class Articles < ROM::Relation[:sql]
schema :articles, infer: true
def ordered_by_recency
order { published_at.desc }
end
end
end
This is enough for us to back to our repository and flesh out that #latest
method. To start with, we need to access our relation. rom sets things up for us so we can refer to relations here by their names, in this case, articles
:
class ArticleRepo < ROM::Repository
def latest
articles
end
end
Now, let's pause at this point to get an instance of this repository.
We need to provide a configured rom environment to its initializer; rom doesn't rely on any global state, so everything must be explicitly passed in.
In real apps, a framework integration will help take care of this for you.
class ArticleRepo < ROM::Repository
def latest
articles
end
end
repo = ArticleRepo.new(rom)
Now we have our instance, we can call our #latest
method and see the articles relation being returned.
We can also see that this relation is indeed wrapping a query to our articles
table:
class ArticleRepo < ROM::Repository
def latest
articles
end
end
repo = ArticleRepo.new(rom)
repo.latest
# => #<Relations::Articles name=ROM::Relation::Name(articles) dataset=#<Sequel::SQLite::Dataset: "SELECT `articles`.`id`, `articles`.`title`, `articles`.`published_at` FROM `articles` ORDER BY `articles`.`id`">>
So let's go ahead and call #ordered_by_recency
on this relation.
We can now see the relation has changed to wrap a different query, with the ORDER BY
clause working on that published_at
column.
class ArticleRepo < ROM::Repository
def latest
articles.ordered_by_recency
end
end
repo = ArticleRepo.new(rom)
repo.latest
# => #<Relations::Articles name=ROM::Relation::Name(articles) dataset=#<Sequel::SQLite::Dataset: "SELECT `articles`.`id`, `articles`.`title`, `articles`.`published_at` FROM `articles` ORDER BY `articles`.`published_at` DESC">>
So far we've only seen relation objects returned, not any actual article records.
This is by design: relations are intended to be chainable and composable.
Every method we call on a relation returns a modified copy of that relation, and we can refine our queries by continuing to chain these method calls.
For our repository to act as an effective interface, however, we want to materialize our relation, asking it to go ahead and load its data from the database.
We can do this by calling #to_a
on the relation, which coerces it into an array of records:
class ArticleRepo < ROM::Repository
def latest
articles.ordered_by_recency.to_a
end
end
repo = ArticleRepo.new(rom)
repo.latest
# => [#<ROM::Struct::Article id=2 title="Cat fingers" published_at=2018-10-15 00:00:00 +1100>,
# #<ROM::Struct::Article id=1 title="Together breakfast" published_at=2018-10-14 00:00:00 +1100>]
Our article records are structs, simple value objects.
These objects carry their data only; no connection at all back to the database.
This is worth dwelling on for a moment, because this represents a major improvement to our application architecture.
Since our records are data only, the repositories serve as the sole interface to the database.
If we need to discover anything at all about how we work with the database, we now have one clear place to look. And there can be no creeping source of bugs from unfettered database access being passed around with our application records.
# => [
# #<ROM::Struct::Article
# id=2
# title="Cat fingers"
# published_at=2018-10-15 00:00:00 +1100>,
#
# #<ROM::Struct::Article
# id=1
# title="Together breakfast"
# published_at=2018-10-14 00:00:00 +1100>
# ]
We aren't limited to working with raw data only. We can customize our article structs. To do this, we want to make our own structs module, then define an Article
class within it, inheriting from ROM::Struct
.
module Entities
class Article < ROM::Struct
def display_title
[published_at.strftime('%d %m'), title].join(" / ")
end
end
end
Then we can configure our repository to use this module as its struct_namespace
:
class ArticleRepo < ROM::Repository
struct_namespace Entities
def latest
articles.ordered_by_recency.to_a
end
end
repo = ArticleRepo.new(rom)
repo.latest
And now the results are instances of our own Article
class:
class ArticleRepo < ROM::Repository
struct_namespace Entities
def latest
articles.ordered_by_recency.to_a
end
end
repo = ArticleRepo.new(rom)
repo.latest
# => [#<Entities::Article id=2 title="Cat fingers" published_at=2018-10-15 00:00:00 +1100>,
# #<Entities::Article id=1 title="Together breakfast" published_at=2018-10-14 00:00:00 +1100>]
Which means we can go ahead and call on any extra behaviour we've defined:
class ArticleRepo < ROM::Repository
struct_namespace Entities
def latest
articles.ordered_by_recency.to_a
end
end
repo = ArticleRepo.new(rom)
repo.latest
repo.latest.map(&:display_title)
# => ["Oct 15 - Cat fingers", "Oct 14 - Together breakfast"]
Let's go one step further. Let's say we want to associate each article to an author, via an author_id
foreign key column, and we want to show these authors when we present our listing of articles.
To make this happen, we start by informing rom of our authors by defining an Authors
relation.
module Relations
class Authors < ROM::Relation[:sql]
schema :authors, infer: true
end
end
Then we can go back into our Articles
relation, and update its schema to include an association: every article belongs_to
an author:
module Relations
class Articles < ROM::Relation[:sql]
schema :articles, infer: true do
associations do
belongs_to :author
end
end
def ordered_by_recency
order { published_at.desc }
end
end
end
Now, with this association in place, we can go over to our repository and update our method there. We tell the articles relation to combine itself with authors:
class ArticleRepo < ROM::Repository
struct_namespace Entities
def latest
articles
.combine(:author)
.ordered_by_recency
.to_a
end
end
repo = ArticleRepo.new(rom)
repo.latest
And as we can see, combine
does what it says on the tin.
class ArticleRepo < ROM::Repository
struct_namespace Entities
def latest
articles
.combine(:author)
.ordered_by_recency
.to_a
end
end
repo = ArticleRepo.new(rom)
repo.latest
# => [#<Entities::Article id=2 title="Cat fingers" author_id=2 published_at=2018-10-15 00:00:00 +1100 author=#<Entities::Author id=2 name="Kat Morris">>,
# #<Entities::Article id=1 title="Together breakfast" author_id=1 published_at=2018-10-14 00:00:00 +1100 author=#<Entities::Author id=1 name="Rebecca Sugar">>]
Every article we're returned is combined with its associated author.
We've taken the time to set up this nested data structure right here in our repository, because unlike ActiveRecord, the records we pass around our application are those simple structs, so we can't just go and load our authors later on.
Instead, rom encourages us to be mindful of our data requirements up front, and fetch everything in one go.
And as a bonus, this entirely eliminates any chance of n+1 problems in our application!
Let's recap. To work with articles in our database, we built a repository to act as the interface to those articles.
# Recap
- Repository
That repository worked with one or more relations, each relation corresponding to a database table and providing the place to store the detailed query logic.
# Recap
- Repository
- Relations
And back from the repository, the database records were returned to us as structs, simple value objects, which we could customise as desired.
# Recap
- Repository
- Relations
- Structs
So we touched on 3 different things to fetch our list of articles. That might seem like a lot of effort for what we otherwise get with a single ActiveRecord
subclass, but it was all for a reason:
# Benefits
It separated our persistence logic into clear layers: repositories as the front door, relations for detailed query logic, and structs for modelling our records.
This helps ensure persistence details don't leak out into the rest of our application, and as our requirements for working with the database grow, it will be important for keeping things organised.
# Benefits
- Layers
The repositories also come with an intentionally narrow API. To build our repositories, we must consider every requirement our app has for database persistence, then name and write our own methods to satisfy those requirements.
This gives us a persistence API that is 100% fit for purpose, with usage that is much easier to trace through the rest of our app.
# Benefits
- Layers
- Fit for purpose
And finally, much of this arrangement can be defined by it's focus, by the things that it doesn't do. rom as a persistence toolkit is dedicated to persistence only.
What you won't find in rom are features for validations or callbacks or HTTP form post handling. All of those are much better handled by dedicated tools and in other parts of our application.
This should result in a much more even spread of logic across our application, across a larger number of smaller, better focused components, all of which should be easier to understand, test, and work on over time.
# Benefits
- Layers
- Fit for purpose
- Focus
So that's our first look into rom-rb.
Next time, we'll see how rom can help us write data back to our database.
Until then, happy hacking!
Responses