Why build an ORM?
Ruby on Rails is powerful partly because ActiveRecord hides a large amount of database plumbing behind a consistent object-oriented API. A call such as:
1 | Comic.find(6) |
looks simple, but several things happen underneath it:
- The model is mapped to a database table.
- A SQL query is generated.
- Query parameters are bound safely.
- PostgreSQL executes the query.
- The result is converted into a Ruby object.
Understanding this pipeline makes it easier to use ActiveRecord deliberately instead of treating it as magic.
This article builds a deliberately small ORM on top of the pg gem. It is not intended to replace ActiveRecord. Its purpose is to make the core mechanism visible.
The complete example is available in the simple-orm-ruby repository.
What we are going to build
The example will support two operations:
1 | Comic.all |
The implementation has three small layers:
Database: owns the PostgreSQL connection.Record: provides behavior shared by models.Comic: maps a Ruby class to thecomicstable.
A real ORM would also need inserts, updates, deletes, associations, validations, transactions, type casting, connection pooling and much more. We will intentionally leave those features out so the essential design remains easy to inspect.
1. Project setup
Create a project with a Gemfile:
1 | source "https://rubygems.org" |
Install the dependency:
1 | bundle install |
For the example, assume PostgreSQL contains a table similar to this:
1 | CREATE TABLE comics ( |
The schema is not part of the ORM itself. It is the database contract that the model expects.
2. Managing the database connection
The original version used a global connection and hard-coded credentials. That approach is convenient for a quick experiment, but it makes testing, configuration and connection lifecycle harder to control.
A small connection module is a better boundary:
1 | # connection.rb |
The important design decision is not the exact environment variable names. It is keeping connection configuration outside the model code. The same code can then run locally, in CI or in production with different environment variables.
For a production application, a single global connection would still be insufficient. The application would need a connection pool so multiple requests could safely share database connections.
3. Building the base record class
The base class will provide:
- a table name;
allto load every row;findto load one row by primary key;- conversion from PostgreSQL rows into Ruby objects.
1 | # record.rb |
3.1. Why use exec_params?
This version deliberately uses:
1 | connection.exec_params(sql, [id]) |
instead of interpolating the value into the SQL string:
1 | # Do not do this with user-controlled input. |
Direct interpolation can create SQL injection vulnerabilities. PostgreSQL parameter binding keeps the SQL structure separate from the values supplied by the caller.
The table name is interpolated because SQL parameters cannot be used for identifiers. In this minimal implementation, the table name is controlled by the model code, not by a request parameter. A production ORM must also validate or safely quote dynamic identifiers.
3.2. Why does find return nil?
Returning nil when no row exists keeps this minimal API explicit:
1 | comic = Comic.find(999) |
Frameworks often provide both non-bang and bang methods, such as find and find!, where the latter raises an exception. That distinction is useful, but it is outside this small implementation.
4. Defining a model
A model only needs to inherit from Record and declare the table it represents:
1 | # comic.rb |
The explicit table name is safer than relying entirely on inflection. A simple rule such as Comic → comics works for this example, but it breaks for irregular names such as Person → people.
5. Using the ORM
1 | # app.rb |
Run the example with:
1 | ruby app.rb |
The ORM is now responsible for querying and materializing records. The application is responsible for deciding what to do with those records.
That separation matters. A data-access layer should return data, not print to standard output or update UI state as the original example did.
6. What this implementation is still missing
The example demonstrates the core mapping, but it is far from a production ORM. A serious implementation would need to address:
INSERT,UPDATEandDELETEoperations;- query composition such as
where,orderandlimit; - SQL identifier quoting;
- type casting from PostgreSQL values to Ruby values;
- validations and lifecycle callbacks;
- transactions;
- associations and joins;
- migrations and schema management;
- prepared statements;
- connection pooling;
- logging, tracing and query instrumentation.
For example, adding a where method is not just a matter of concatenating a condition to the SQL string. Values must still be parameterized, and column names must come from a trusted or validated set.
7. How this relates to ActiveRecord
ActiveRecord solves the same fundamental problem at a much larger scale:
1 | Comic.where(published: true).order(created_at: :desc).limit(10) |
Behind this interface, ActiveRecord builds a query representation, generates SQL, binds parameters, executes the query and wraps each row in a model object.
The difference is the amount of infrastructure around the core idea. ActiveRecord also handles associations, scopes, callbacks, validations, transactions, schema conventions, adapter differences and integration with the Rails lifecycle.
Building a minimal ORM is therefore not about recreating Rails in a few dozen lines. It is about isolating the smallest useful abstraction and seeing exactly where the complexity begins.
Conclusion
An ORM is a translation layer between two different models of the world:
- relational databases organize data into tables, rows and columns;
- object-oriented programs organize behavior and state into objects.
The essential responsibilities are straightforward:
- map a model to a table;
- execute SQL safely;
- convert rows into objects;
- expose a useful application-level interface.
The difficult part is everything required to make that interface reliable at scale: query composition, transactions, concurrency, type systems, performance and failure handling.
A small ORM cannot replace ActiveRecord, but it can make ActiveRecord much easier to reason about.