Versioned Records in Rails Without a Gem

Derrick Otte
6 min readSep 2, 2020
Screenshot of a web app showing customer versions being created

In every company I’ve been a part of, a need always comes up — the ability to track changes on something in the app. Customers, orders, items, whatever it might be, things change often, and it’s helpful to see what changed, and who changed it.

Most of the time for larger apps, an off the shelf solution is probably the best bet. You get a dedicated team for maintenance and documentation of the solution, without having to invest tons of money into maintaining your own solution.

But for small projects, it could be worth it to build it yourself. I wanted to share what I came up with for anyone who ran into this in the future — creating versioned records in Rails with no external gems, just ActiveRecord and ActiveModel::Dirty.

Before diving in, I’ll be going over in-depth Rails practices, SQL, and MVC terminology. If you’re not familiar with those, or don’t have Rails installed on your machine, I would recommend checking out the Rails guides before hand.

Part 1: What will the tables look like?

The biggest challenge when I started working on this was scalability. Think of a hypothetical E-Commerce company. The company sells Products, which contain Items. These could both be updated dozens or hundreds of times a day.

A lot of off the shelf solutions use a singular Versions table for all tables. What happens when you’re creating thousands or hundreds of thousands of versions? In my experience, querying that table and picking out the specific versions you need is a huge PITA.

If you’re building it yourself, my personal take is to create a cloned version table for every table that needs version history. So if you have a Products table, you would create a ProductVersions table.

With this method, your version tables can grow in size independently. If your ProductVersions table is 100,000 records, it won’t affect your CustomerVersions table that only has 500 records.

Initial Migrations

Now that we know what the tables will look like, we’ll create a new Rails project. Once created, we’ll create two models: Customer and CustomerVersion. First, create a new rails project:

rails new versioned_records

Next, create a Customer model with a few attributes: name, primary contact, and currency.

rails g model Customer name:string primary_contact:string currency:string

It should create a migration file inside db/migrate with your 3 attributes, a Customer.rb model, fixtures, and a test file.

Now that Customer.rb is created, you can create it’s versioned model: CustomerVersion.rb with identical attributes: name, primary contact, and currency.

rails g model CustomerVersion name:string primary_contact:string currency:string

It should also create a migration file with your 3 attributes, a model, fixture and a test file. However, before running these migrations, we’re going to change the migration file slightly. Currently, your migration file will look like this:

This is a good start, but two things need adjusted: we need to remove t.datetime :updated_at, and establish the relationship between Customer and CustomerVersion with a t.belongs_to:

But why are we doing this?

If you think about it, versioned records are only ever going to be created. Updating a versioned record wouldn’t make any sense, right? Because of this, the column updated_at will never be needed here, so it can be removed.

t.belongs_to :customer will tell the table to establish a foreign key referencing the Customers table id column, and will add an index for faster searching with index: true. If you’re unfamiliar with some of this language, the ActiveRecord associations guide is super helpful. It also goes through the SQL behind it as well.

With these migrations completed, you can run migrations with rails db:migrate.

Part 2: Creating the ActiveVersions Module

Now that the tables are created, we can get to the fun stuff — actually creating versioned records.

We’re going to use Rails’ ActiveModel::Dirty to achieve this. ActiveModel::Dirty is a Rails module that hooks into SQL inserts/updates and lets you track changes to ActiveRecord objects, in our case, our Customer.rb model.

To start, create a new file in your Rails app under app/lib (you might need to create the lib directory too) named active_versions.rb. This will be a a module that can be included in Rails models all over your app.

With about 13 lines of code, we can create versioned records! But let’s step through this code a bit.

If you’re unfamiliar with class and class instance structure in Ruby, it might be helpful to read a guide before going forward. This also ties in to what ActiveRecord is and does, and the Rails team has a great documentation page on it.

First, we defineActiveVersions as a module so that models can include it. Then, create_versioned_record takes in an ActiveRecord object, in this case an instance of Customer, and grabs it’s versioned model as version_history_class.

It then uses ActiveModel::Dirty’s previous_changes method to see what changed in the database for that record, skipping the updated_at/created_at keys because we don’t need them.

Here’s a good example of what previous_changes looks like:

Screenshot of self.previous_changes object showing it’s properties and values
An example self.previous_changes object from a separate project

After creating the new_version object, it creates a new version in the database, under the belongs_to of the original model. This is what it would look like with Customer and CustomerVersion:

Customer.send(customer_versions).create!(new_versions)

Part 3: Hooking it up to ActiveRecord models

Now that the VersionHistory module is created, we need to start using it in the Customer model. You should have a customer.rb file in your project, under app/models/. Open it up, and make some changes:

Let’s go through the changes. First, we included the ActiveVersions module, so that we can use the create_versioned_records method.

Second, we added the customer versions relationship through has_many :customer_versions, and made sure that if the customer is destroyed, it’s versions are destroyed with dependent: :destroy.

Next, we use ActiveRecord’s after_commit method to call a method when a customer gets created, updated or deleted. We call create_versioned_record, and pass self, the current instance, only if the record actually persisted in the database, and only on updates.

The last thing that’s needed is to open up the model customer_version.rb inside of app/models/ and add the relationship back to customer through belongs_to:

And that’s it! If you open up a rails console and test it out, you should see customer versions being created on update of customers. You can also read on to write tests for it specifically.

Part 4: Testing it Works

Testing this is pretty easy too. Inside of test/models/customer_test.rb, we’ll write 3 tests:

To summarize, one test is for creating versions, another tests that just creating a customer doesn’t create new versions, and another (commented out for now) tests a future case where the name column isn’t included in version history. Running the tests now, you should see green.

If you’d like to see a more complete version of this project, and one that you can test out yourself, check out the Github project I’ve created it for it.

You can also read on for bonus functionality.

(Bonus) Part 5: Custom Functionality

One of the benefits that you get from this solution is the ability to add your own functionality.

To show this, I want to add the ability for some attributes on each model to not be tracked through versioned records. It’s pretty easy to do!

In Customer, we’ll remove the name column from being tracked through versioned records. The customer name hardly ever changes, right? To do this, add this line to the model:

NON_VERSIONED_ATTRIBUTES = %w[name].freeze

This creates a constant of column names that don’t need versioning that can always be added to in the future.

Back in app/lib/active_versions.rb, we’ll account for this when creating records on lines 4 and 8:

And in just a few lines of code, custom functionality is added to this! Still not using extra gems. If you want to add tests for this, you can un-comment the third test up above and you should see green.

--

--