Noteflakes iconnoteflakes

Beyond ORMs

05·09·2026

I’ve always been interested in the design of ORMs - I wanted to know how they work, what the different approaches are, and how they can be used. I tried a bunch of different ones, I even created one. But in the last few years I’ve been slowly gravitating toward working more directly with databases - issuing queries using plain SQL, processing raw data coming from the database, or building custom low-level abstractions for working with relational databases. This evolution in the way my code talks to databases is the reason I created Extralite, a Ruby gem for working with SQLite databases.

Why do I prefer direct interaction with the database instead of using an ORM? For any developer well-versed in SQL the limitations of ORMs should be obvious - there’s no support for features such as window functions, common table expressions (CTEs), or even the RETURNING clause; the ORM layer itself is a substantial dependency (the ActiveRecord codebase is about ~43KLoC), it imposes a performance cost in terms of memory and CPU time; and its defining feature, the mapping of rows to objects, is an anti-pattern that has been described as “the Vietnam of computer science”.

Now, I’m well aware of the fact that ActiveRecord is by far the most popular way to talk to a database in the Ruby/Rails community, and has greatly influenced many other ORMs for Ruby and other languages. But to me ActiveRecord has always felt wrong. The longer I work building web apps and platforms, the clearer it is to me. ActiveRecord provides the wrong abstraction for relational data. ActiveRecord concentrates on the single record, the idea that each record is an entity in and of itself. In reality, however, a normalized database will contain many tables with ancillary data, such as tags or labels, which does not need to be treated as entities. There’s time series data and event logs. Nowadays relational databases are even used as key-value stores and job queues. For these types of data, we’re frequently interested in record sets rather than individual records, and we don’t really need the entity abstraction. For those uses, ActiveRecord seems clumsy and wasteful.

Since ActiveRecord’s API revolves around the idea of a separate object for each row, descended from ActiveRecord::Base, with its hundreds of instance and class methods, programmers may get the idea that they’re actually dealing with discrete objects, and not entries in a record set. With ActiveRecord, a novice programmer might want to delete or update multiple records using #each, e.g.:

def make_bracelet(material)
  beads = Bead.where(material:).all
  bracelet = Bracelet.new(beads)
  beads.each(&:delete) # <== a separate DELETE query for each bead
  bracelet
end

In the example above, we issue one SELECT query to get the beads we’re interested in, and then for each post we’ll issue a DELETE query, so we got a N+1 situation. Of course, a simple solution would be to call delete_all instead of looping over the beads we read:

def make_bracelet(material)
  beads = Bead.where(material:).all
  bracelet = Bracelet.new(beads)
  Bead.where(material:).delete_all
  bracelet
end

So now we’re down to two queries, a SELECT query and a DELETE query, both of which have the same WHERE clause:

select * from beads where material = ?;
delete from beads where material = ?;

One problem with this is that we’re not guaranteed that those two queries will touch the same records, unless we run the two queries inside a transaction. A second problem is that we’re still doing two round-trips to the database. Can’t we both read and delete records in a single query? Yes we can, at least in databases such as PostgreSQL and SQLite, using the RETURNING clause:

delete from beads where material = ? returning *;

Running this query will delete the relevant records, and return their content. As far as I could tell, there’s no API for this kind of query in ActiveRecord. But all we need is a just a way to run a plain SQL query, which should be easy using Extralite:

def make_bracelet(material)
  beads = @db.query <<~SQL
    delete from beads where material = ? returning *
  SQL
  Bracelet.new(beads)
end

What we’ve achieved here is to reduce the interaction with the database to a single query that both deletes the relevant rows and returns their content. Yes, this means that we express queries in plain SQL, which might give you pause, but bear with me, I’m going somewhere with this.

The DSL Trap

One of the main selling points of ActiveRecord and ORMs in general is the convenience of a DSL: no need to write SQL, just use our magical DSL to construct any query you want. So in fact what happens is that you get a terrific general-purpose API with literally hundreds of instance and class methods, that allows you to dynamically build queries however you wish. But do we really need all those hundreds of methods?

Something I’ve observed in practically every web app codebase I ever looked at, is that, except in very rare cases, the number of different queries a given app will make is finite, and in fact relatively small. All those CRUD apps - written by CRUD monkeys 😉 - they basically just issue a few different types of queries. For example, a simple blog app might perform the following different queries:

Post.Create(title: 'foo', body: 'bar')            # create
post = Post.find(42)                              # read
post.save                                         # update
post.delete                                       # delete
Post.order_by(:stamp).all                         # list
Post.where(category: 'baz').order_by(:stamp).all  # list by category

Your app might want to bring in some associations, it might want to be able to filter and sort posts in different ways, so it’s going have to make a few other queries, but their number would average, I’d guess a rough estimate, maybe a dozen different queries per table. That, unless you’re building a full-featured visual query builder app!

So I think the question that should be asked is: if we only need to make a few dozen different queries, why should we use a DSL in the first place? Do we really need all of ActiveRecord’s magic and expressivness? Why not just express those queries directly in SQL? Compared to the amount of Ruby code in your app, the amount of SQL you’ll have to write is a drop in the bucket!

Now, one could argue that the biggest advantage of using ActiveRecord over writing plain SQL queries would be the lack of boilerplate and all the features you get for free, of which associations are perhaps the most important. Then again, in my opinion those abstractions have a way of crumbling under their own weight the moment you try to do something a bit more advanced or esoteric, such as incorporating non-entity data in your queries, or using window functions for example.

I think interacting with relational databases without having a basic grasp of SQL is not a good idea. Yes, vibe-coding has taken our profession by storm, and lots of people apparently think that the code doesn’t matter anymore and that we shouldn’t even look at it, and that we’re now all prompt monkeys and we should all just spend our day paying lots of money to Anthropic, watch our “quotas”, come up with creative ways to economize tokens (what a silly notion!) and building all those groovy exotic hyper-complex loops and harnesses and “skills” and AGENTS.md files and all that nonsense, instead of, you know, just writing normal code that works.

It is the present author’s opinion that understanding your code still matters, understanding what your database is doing still matters, performance still matters, and having at least a modicum of frugality in using compute resources still matters (actually it matters even more considering our present environmental challenges!)

Besides, I find it interesting that on one hand so much effort is being expended on making the Ruby runtime faster, yet developers who use Ruby on Rails seem to have such a cavalier, almost ignorant, attitude to performance: “who cares, compute is an infinite resource, the agent will take care of it, we’ll just tell it to make the code faster”. And indeed, who can fault them? As long as AI platform prices do not reflect the true cost of AI compute, why should people who vibe-code care at all about the performance of their own code (which they haven’t looked at!), why should they care about getting the most out of their compute infrastructure? In that sense, the absolutely amazing work done by a few very talented developers on making Ruby itself faster, is a sisyphean task, taking into account the ridiculous amount of unstoppable slop being added daily to actual Ruby on Rails apps. But I digress.

Rethinking the M in MVC

It has long been accepted that the M in MVC is supposed to be some kind of an ORM, a layer whose main responsibility is mapping table rows to entity objects, and providing an expressive API for getting a hold of such objects and manipulating them. But maybe instead of interacting with the database using a general-purpose query builder, we can come up with our own custom made API for interacting with the database. Let’s retake the example of a simple blog app, and imagine we had an interface that’s custom-made for dealing with posts:

posts = PostsStore.new(db)
id = posts.create(title: 'foo', body: 'bar')  # create
post = posts.by_id(id)                        # id
posts.update_by_id(id, title: 'FOO')          # update
posts.delete_by_id(id)                        # delete
posts.all                                     # list
posts.all_by_category(category: 'baz')        # list by category

Those are the same six queries as before, but the methods that return rows do so using plain Ruby hashes instead of custom objects. From the point of view of the app, this is just a change of interface, we’ve essentially created a bespoke API for reading and manipulating posts for our blog app. Just like with ActiveRecord, the whole database layer is abstracted away in a set of methods, the controller/business logic code doesn’t need to use a Post class with its deep, chainable API, it just calls methods on an interface.

The most important change, hoewever, is that whenever we deal with posts, we cannot just invent new queries, we have to use the ones that exist, or add new ones to the PostsStore class. The implementation of PostsStore is quite simple:

class PostsStore
  def initialize(db)
    @db = db
  end

  def create(title:, body:)
    @db.query_splat <<~SQL, title, body
      insert into posts (title, body)
      values (?, ?)
      returning id
    SQL
  end

  def by_id(id)
    @db.query_single_row <<~SQL, id
      select id, title, body
      from posts
      where id = ?
    SQL
  end

  ...

  def all
    @db.query <<~SQL
      select id, title, body, stamp
      from posts
      order by stamp desc
    SQL
  end
end

Here we’re taking advantage of one of Extralite’s defining features - the ability to extract data in however form you want, be it a single value, a single row, or a set of rows. Extralite also has some more advanced features, as I’ll show below.

Look at all the things we’re not doing: there are no entity objects, the data we want is returned from the database in the form we need, in plain Ruby hashes, Everything is explicit and easily understandable - the queries, the parameters, the columns. We’ve cut down substantially on the number of allocations we make. And of course, this kind of code can be easily scaffolded (for CRUD usage) or even generated by your favorite slop agent if you’re so disposed.

Also, look at how we removed all the unnecessary abstractions: there’s no entity classes, there’s no DSL driving the building of queries. We’re just talking to the database directly, with the help of Extralite, and we provide a convenient API that abstracts the database layer.

The Model Layer is an API

This design might seem baffling at first - where’s the ability to create queries on the fly as the app is developed? Where is the interaction with entities? Where do I put my business logic? The answer to all these questions is: the store class. The store class encapsulates everything that has to do with a certain kind of data. The store class should function as the interface for interacting with posts. Whatever you need to do with posts, it should be in the PostsStore class. For example, if you need to read posts with associations, just add a method that performs the right query and returns the data with the associations included. Here too, Extralite can help us, since it can transform projections of joined rows, effectively converting a result set into an object graph:

class PostsStore
  POSTS_WITH_AUTHORS = Extralite::Transform do
    {
      id:       integer.identity, # posts.id
      title:    text,             # posts.title
      body:     text,             # posts.body
      stamp:    integer,          # posts.stamp
      author:   {
        id:     integer.identity, # authors.id
        name:   text              # authors.name
      }]
    }
  end
  
  def all_with_authors
    @db.query POSTS_WITH_AUTHORS, <<~SQL
      select posts.id, posts.title, posts.body, posts.stamp,
             authors.id, authors.name
      from postss
      join authors on authors.id = posts.author_id
      order by posts.stamp desc
    SQL
  end
end

Now, you might say: all this code for something I could’ve gotten for free with ActiveRecord! Yes, it requires some SQL and support code to be written in order to implement this, but from the point of view of your app, the API is just as simple as before, and the data you get out of the store interface contains everything you need, only it’s expressed using plain Ruby hashes:

# here's how a view might look like, using Papercraft:
POSTS_VIEW = ->(posts:) {
  div(id: 'posts') {
    posts.each { |p|
      div(class: 'item') {
        h3 p[:title]
        h4 p[:author][:name]
        markdown snippet(p[:body])
      }
    
  }
}

# Here's how a controller might look like, Using Syntropy:
def call(req)
  posts = @posts_store.all_with_authors
  html = LAYOUT.render(posts:, &POSTS_VIEW)
  req.render_html(html)
end

Frankly, how is this any more difficult than using ActiveRecord? In addition, instead of deep, chained method calls such as Post.where(...).order_by(...) spread all over our app’s codebase, we have created a special-purpose interface custom made for our specific kind of entity (blog posts) that deals with everything we want to do with them, and abstracts them behind regular method calls, no magic involved.

Another detail I’d like to address is the fact the posts store is implemented as a class, when in fact it is used more like a singleton. The reason I’m implementing it as a class is to be able to inject a database connection into the store object. You can do it in many other ways if you wish, depending on your needs. For example you might want to pass in a connection pool instead of a connection, or just implement the interface as a global singleton module. It all basically comes down to the same idea: the model layer as an interface, not as a DSL driving classes of entity objects.

The store abstraction (it’s really just an interface) can also be used to interact with non-entity data, such as time series data, auxiliary data, a key value store, a job queue etc. A store doesn’t even need to correspond to a single table. Since its building blocks are SQL queries, you can access any number of tables, using all available SQL features, in order to read and manipulate the relevant data.

Passing Interfaces Around

One aspect of this design that merits further discussion is the idea of passing interfaces as parameters to method calls. In Ruby, we don’t really talk about interfaces, we talk about discrete objects with which we interact directly. An interface is also an object, but it doesn’t encapsulate data (though it may have some state), it encapsulates functionality. It is in fact, a container of methods.

I’ve been using this “interface pattern” for quite a while. In UringMachine for example, I/O is performed using an interface, an instance of UringMachine or UM for short:

require 'uringmachine'

machine = UM.new
machine.write(UM::STDOUT_FILENO, "hello, world!")
machine.open('foo.txt', UM::O_RDONLY) do |fd|
  buf = +''
  size = machine.read(fd, buf, 8192)
  machine.write(UM::STDOUT_FILENO, buf)
end

In essence, all I/O operations are done through this interface, which means that you need to have a reference to the interface anywhere you do I/O. This design is not unique to UringMachine. Most notably, the Zig programming language now implements I/O as an interface, which is passed around as a parameter (this, in addition to an allocator interface). Go is another example of ubiquitous usage of interfaces.

While this means that you need to pass the interface object around to different parts of your app, you can use various techniques to simplify working with the interface. One way is to use dependency injection. We saw an example of this above, where we inject a database instance into a PostsStore instance. The same can be applied to UringMachine, where we pass the machine instance to an object that abstracts an HTTP connection:

class HTTPConnection
  def initialize(machine, fd, &handler)
    @fd = fd
    @machine = machine
    @handler = handler
  end

  def respond_empty(status = 200)
    @machine.write(@fd, "HTTP/1.1 #{status}\r\nContent-Length: 0\r\n\r\n")
  end
end

A related approach is by using closures, which is especially useful when dealing with callables:

def make_posts_handler(posts_store)
  ->(req) {
    posts = posts_store.all
    req.respond_html(render_posts(posts))
  }
end

app.start(&make_posts_handler(@posts_store))

One important consequence of using interface objects, is that it encourages you to build your app in a more responsible way. For example, you might be tempted, unless you knew better, to read or manipulate posts somewhere in the bowels of a view template. Well, with this kind of design, you can’t, unless the template code has gotten a hold of a PostsStore instance, which is a bad idea and should be verboten. Thus, you can make sure that any part of your code that doesn’t hold a reference to a PostsStore interface can’t touch the database, which should contribute substantially to you keeping more hairs on your head!

Taking Advantage of Prepared Statements

But let’s get back to ORMs. Another missing feature in ORMs is the ability to use prepared statements. Prepared statements, in SQLite and I believe also in PostgreSQL are queries that have been prepared in advance and can be executed again and again by the application, without the database having to parse the query again and again each time it is executed. Prepared statements are ephemeral - they exist only for the duration of the database connection. In SQLite, those are simply normal queries (statements in SQLite tech lingo) that are kept in memory in order to be reused, instead of being discarded directly after being executed.

Since we’re dealing with (in most cases) a finite number of different queries, why should the database have to parse over and over again the same queries? We can use prepared statements for that. While Extralite does have an Extralite::Query class that implements prepared statements (or queries), I’ve been working recently on automatic caching of statements at the database level, such that any query that’s issued with parameters is stored in a cache, and automatically reused whenever the same SQL is given to Database#query.

The code is not yet released (hopefully it’ll be ready by the end of the month), and I still haven’t done any benchmarking to see how it affects performance. Using this feature you will get slightly higher memory usage (each prepared query consumes a few KBs of RAM), but you’ll reduce CPU time, and you’ll also reduce allocations.

I’m excited to see where this goes, and how far we can push the idea of getting the most out of SQLite databases. Ruby is hella fast nowadays, and it’s only getting faster and better. now it’s our turn to get rid of wasteful and unneeded abstractions, and engage again in writing faster, leaner, better software.