DEV Community

Nathan Kallman
Nathan Kallman

Posted on • Edited on • Originally published at kallmanation.com

Immutable ActiveRecord

I just merged something at my job that I'm proud of. I was inspired by another post here, but I'm especially happy with mine as I think I've covered a few holes Adam Rogers left in their example as well as added some needed niceties for my use-case. Ready? Let's get stuck into it:

Why?

If you already know why you want an immutable model; then go ahead to the next sections for the goodies. This section is for the rest of you.

Immutability is a bit of a buzzword around tech these days. All it means is unchangingness: once something exists it cannot be altered; a tattoo or stone tablet if you will. Only new things can be added, old things cannot be changed.

Without immutability our records can be something of a Schrödinger's cat; always either the last state we observed it in or some other random state (and we won't know until we observe it again). It also means there is no record of history; we have no clue how we arrived at the current state.

Root, being an insurance company, needs to regularly prove, to intense detail, how it arrived at the current prices it gives to customers, how it handled claims and payouts on policies, and really every detail of the business.

Have you tried proving that your history record is accurate when anyone or anything can change it? "It's Immutable" becomes your favorite word when an auditor starts asking questions.

A Word on Adam's Approach

If you are familiar with Rails you'll probably notice a few holes in that article's example (which is fine for exemplary purposes, but not for me in production):

  1. It doesn't protect against .destroy 'ing records
  2. It doesn't protect against updating with .save

Now, it's easy enough to add the needed before_save and before_destroy hooks and prevent those too. But the other word I have for why I took my approach is that Root's codebase generally shuns ActiveRecord's hooks (because of the side-effects and coupling problems) and my solution is much simpler to implement and test (I don't even need to extend ActiveSupport::Concern !)

Readonly?

Yes, .readonly?. Which if you didn't know is an ActiveRecord method on all models that by default returns false and returns true if you've previously called .readonly! on the object.

But what makes this especially interesting for us is that ActiveRecord checks .readonly? before any .create, .update, .save, or .destroy call and raises an error (ActiveRecord::ReadOnlyRecord) if .readonly? returns true. ActiveRecord also handily provides a .new_record? method to clue us in on if this record has been saved to the database yet. So maybe all we need to do is write our .readonly?



module ImmutableRecord
  def readonly?
    !new_record?
  end
end


Enter fullscreen mode Exit fullscreen mode

Done! ...well not exactly. Remember what I said about .readonly! being able to mark any object as... well readonly? We've kind of broken that functionality by so naively overriding .readonly?. That's not very nice, so let's restore it to default behavior:



module ImmutableRecord
  def readonly?
    return true unless new_record?
    super
  end
end


Enter fullscreen mode Exit fullscreen mode

And that finishes our use of .readonly? (mostly). But I wasn't done. I still had a few changes in mind.

Allow Mutation!

It was great that I could now have immutable records; but sometimes I still need to update them (ever added a column to a table and needed to set the value for existing records?).

Escape hatch coming up! But we don't want too broad of an escape hatch (opening doors for accidental abuse and mutation). I chose to utilize blocks so the window for mutation has a distinct, language enforced, start and end.



module ImmutableRecord
  def allow_mutation!
    current_mutation_allowed = @mutation_allowed
    begin
      @mutation_allowed = true
      result = yield self
    ensure
      @mutation_allowed = current_mutation_allowed
    end
    result
  end

  def readonly?
    return true if !new_record? && !@mutation_allowed
    super
  end
end


Enter fullscreen mode Exit fullscreen mode

Stash the current value of a new attribute (current_mutation_allowed = @mutation_allowed), and use an ensure block to ensure, even if the given block raises an error, the attribute is set back to its previous state. Two more niceties: return the result of the given block and yield self (aka the current model) for easier chaining. Wrap up the changes by making .readonly? account for our override attribute and we have a little escape hatch! Usage looks something like immutable_model.allow_mutation! { |m| m.update!(...) }.

Immutable?

I had one last interesting use case I needed. On a particular model, it is usually safe to do any updating or even deletion; but in a particular scenario, we risk serious data loss if the record is touched at all*. I needed one more tool to allow the model to switch on and off immutability.

So I came up with .immutable?. By default this always returns true, but the model is free to override the definition when it needs to turn off immutability based on current state.



module ImmutableRecord
  def allow_mutation!
    current_mutation_allowed = @mutation_allowed
    begin
      @mutation_allowed = true
      result = yield
    ensure
      @mutation_allowed = current_mutation_allowed
    end
    result
  end

  def immutable?
    true
  end

  def readonly?
    return true if !new_record? && immutable? && !@mutation_allowed
    super
  end
end


Enter fullscreen mode Exit fullscreen mode

Of course I could have skipped .immutable? and had my model implement .readonly? for itself. But that means re-implementing the knowledge of the interaction between super, new_record?, and @mutation_allowed. Adding .immutable? lets the model focus more tightly on the things it actually concerns itself with and leaves the rest to this concern (I guess that's why ActiveRecord calls these concern 's).

Remaining Gaps

I mentioned earlier that ActiveRecord checks .readonly? during .create, .update, .save, and .destroy; essentially all the methods that also call other lifecycle hooks like validations and before_ / after_.

What ActiveRecord does not do is check .readonly? before methods like .delete or .update_columns. Meaning there are still easy ways to mutate our "immutable" records. For my purposes these holes were acceptable and these methods should be expected to bypass lifecycle checks as that is their purpose.

If you'd rather not have these bypasses to immutability, then you'll need to override each method in this concern to first check .readonly? (and raise an error if it is) and then call super.


* So you are curious about this strange model that is sometimes immutable sometimes not? Well alright, I'll tell you:

The gist of it is backfilling. There was an older model that we just updated on certain events. That turned out to not be working well. So I created a new model wherein we simply take the full information we needed from the models triggering those events and idempotently generate the full history in the format we needed for tracking; only then comparing to what was already stored in the database and making the minimal creations/updates to store the full history.

Since that's an idempotent process and the source of truth comes from other tables; we are usually free to update and even delete these new records however we please.

The problem came when I needed to backfill the information from the old table into this new table. I needed to keep the historical records exactly the same (and I mean exactly). So I simply one-for-one copied data from the old table to the new, and marked those records with a column as being backfilled. While it is normally safe to do any mutation against this table; these backfilled records really should not be mutated (because they came from the old table; not the normal process).

To add a guardrail against us accidentally mutating those backfilled records, I used this ImmutableRecord concern with the .immutable? method overridden to simply return .backfilled?.

Top comments (0)