DEV Community

Cover image for The Dangerous Elegance of ActiveRecord::Store
Zil Norvilis
Zil Norvilis

Posted on

The Dangerous Elegance of ActiveRecord::Store

As Rails developers, we are obsessed with schema design. We love 3rd Normal Form. We love foreign keys. We love data integrity.

But the real world is messy.

Sometimes you are building an E-commerce platform where a T-Shirt has a size and material, but a Laptop has a cpu_speed and ram_slots.

How do you model that in a relational database?

  1. Single Table Inheritance (STI)? You end up with a products table with 50 columns that are mostly NULL.
  2. EAV (Entity-Attribute-Value) Tables? Please, no. The SQL queries alone will make you cry.
  3. Separate Tables? Now you can't easily query "all products."

This is the sweet spot for ActiveRecord::Store. It is the bridge that lets you treat your SQL database like a Document Store, without abandoning the safety of Active Record.

The "Polymorphic" Product Problem

Let's look at how ActiveRecord::Store solves the "variant" issue without creating a database nightmare.

The Migration

You only need one column. Make it jsonb (Postgres) so you can actually index it later if you get desperate.

add_column :products, :properties, :jsonb, default: {}
Enter fullscreen mode Exit fullscreen mode

The Model

Here is where the elegance happens. We don't just dump data into a hash; we define a schema in code rather than in SQL.

class Product < ApplicationRecord
  # Base properties for all products
end

class Tshirt < Product
  store :properties, accessors: [:size, :material, :color], coder: JSON

  validates :size, presence: true
end

class Laptop < Product
  store :properties, accessors: [:cpu, :ram, :storage_type], coder: JSON

  validates :cpu, presence: true
end
Enter fullscreen mode Exit fullscreen mode

Now, when you interact with a Tshirt, you treat size exactly like a database column.
tshirt.update(size: 'L') works.
form_for @tshirt works.
Strong Params works.

Rails hides the JSON serialization completely.

The Hidden "Gotchas" (Read this before deploying)

If ActiveRecord::Store is so great, why don't we use it for everything? Because it comes with three specific "footguns" that can hurt you if you aren't careful.

1. The "String vs. Symbol" Trap

When you save data to a JSON column, it is serialized. When you pull it back out, keys are strings.
Rails tries to help, but if you access the raw hash, you might get burned.

product.properties[:size] # might be nil
product.properties["size"] # might be 'L'
Enter fullscreen mode Exit fullscreen mode

Solution: Always use the accessor methods (product.size), never the raw hash.

2. The Type Casting Issue

In a normal Rails integer column, if you save "5", Rails makes it 5.
In a Store, if you save "5", it stays "5" (String).

Because the underlying column is JSON, it creates a flexible typing system that Rails validations sometimes ignore until it's too late.

Solution: Use the attribute API explicitly if you need types.

# Modern Rails way to force types inside a store
attribute :ram, :integer
Enter fullscreen mode Exit fullscreen mode

3. Dirty Tracking

In older versions of Rails, changing a single key in the store marked the entire column as dirty. This meant if two people updated different "attributes" in the store at the same time, the last one to save would overwrite the other's changes (Race Condition).

Solution: Be very careful with high-concurrency updates on Stored columns.

When to use it vs. When to run a migration

I use a simple decision matrix:

  1. Is this data needed for a JOIN?
    • Yes -> Real Column.
  2. Is this data needed for frequent ORDER BY or GROUP BY clauses?
    • Yes -> Real Column.
  3. Is this data "content" specific to a subtype of the model?
    • Yes -> ActiveRecord::Store.
  4. Is this data purely cosmetic (UI settings)?
    • Yes -> ActiveRecord::Store.

The Modern Alternative: store_model

If you find yourself loving ActiveRecord::Store but hating the lack of types and nested validations, check out the store_model gem.

It allows you to wrap your JSON column in a real Ruby class (ActiveModel) with its own validations, seemingly giving you the best of both worlds:

class Specs
  include StoreModel::Model
  attribute :cpu, :string
  attribute :ram, :integer
  validates :ram, numericality: { greater_than: 0 }
end

class Laptop < Product
  attribute :properties, Specs.to_type
end
Enter fullscreen mode Exit fullscreen mode

Conclusion

ActiveRecord::Store is one of those Rails features that feels like cheating. It allows you to iterate on features at lightning speed without being bogged down by migrations.

Just remember: It is duct tape. It is incredibly useful, strong, and versatile. But you probably shouldn't use it to build the foundation of your house.

Top comments (0)