StoreModel: ActiveModel classes wrapped around JSON database columns
Work with JSON-backed attributes as ActiveRecord-ish models
At a glance
- What is it?
- StoreModel is a Ruby gem that lets a JSON column be backed by a real class with typed attributes, validations, enums and nested attributes, while the data stays in one column. It suits Rails applications whose jsonb fields grew logic, and it asks you to think about change tracking when you mutate a stored model in place.
- Who is it for?
- Adopt StoreModel if your Rails models have jsonb columns that have grown validation rules, enums or nested structures, because the gem gives those fields a class without a migration and without a join table. Do not adopt it for data that needs to be queried, indexed or constrained by the database, because a JSON column makes that awkward whatever class you put in front of it.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 47 days ago.
- What is it written in?
- Mainly Ruby, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem: a jsonb column that grew logic
StoreModel allows you to wrap JSON backed database columns with ActiveModel-like classes, and the README opens with the exact code it is meant to replace.
product = Product.find(params[:id])
if product.configuration["model"] == "spaceship"
product.configuration["color"] = "red"
end
product.saveThe README's judgement of that code is that it works fine when you do not have a lot of keys with logic around them and just read the data, and that the code becomes verbose and error prone when you start working with the data intensively.
The specific pain point it names is validation. Making the model value required has to be written by hand, and doing that in the parent Product model violates the single responsibility principle, because the question is why the parent should know the logic of the child.
That is the argument for the whole gem, and it is a design argument rather than a performance one. The data stays in a jsonb column, so there is no migration and no schema change. What changes is that the logic around the data moves into a class that owns it.
The gem is MIT licensed, the last push to the repository was on 2026-09-06, and it was initially sponsored by Evil Martians, who also published the write-up on wrapping JSON based ActiveRecord attributes with classes that the README links for the longer version of the motivation.
Defining the wrapper with the Attributes API
The wrapper is a plain Ruby class that includes a module.
class Configuration
include StoreModel::Model
attribute :model, :string
attribute :color, :string
endAttributes are defined using the Rails Attributes API, and the README notes there are a number of types available out of the box and that the type system can always be extended.
The Attributes API is the load-bearing choice here, and the reason is worth understanding. Because the attributes are declared the same way ActiveRecord declares them, they are cast on assignment and on read, which means a string that comes back from the database as a number or a boolean arrives as the right Ruby type rather than as whatever JSON happened to hold. It also means the declaration reads like the rest of a Rails model, so a developer who knows ActiveRecord already knows this.
The gem goes further than plain attributes, and the README claims the gem works like ActiveModel, with validations, enums and nested attributes working very similarly to the APIs provided by Rails.
class Configuration
include StoreModel::Model
attribute :model, :string
enum :status, %i[active archived], default: :active
validates :model, :status, presence: true
endSo the class carries a status enum with a default and a presence validation on both fields, and the validation runs before the parent record is saved rather than being hand written in the parent.
Registering the type on the ActiveRecord model
The link between the class and the column is a single attribute declaration on the ActiveRecord model, using the to_type method on the wrapper class.
class Product < ApplicationRecord
attribute :configuration, Configuration.to_type
endThat is the whole integration. There is no serializer to register, no coder to configure, and no per instance wiring. The column keeps its type in the database, and the type object on the model knows how to read it into the class and write it back.
The result is that the code from the beginning of the README becomes ordinary method calls on a typed object.
product = Product.find(params[:id])
if product.configuration.model == "spaceship"
product.configuration.color = "red"
end
product.saveThe difference in behaviour is not only readability. The comparison against a string key returned a string or nil, whereas the comparison against a typed attribute returns a value that has been cast, and the assignment goes through the attribute rather than into a hash.
The documentation list at the end of the README shows how much surface there is behind that one line, with separate pages for instantiation, validations, enums, nested models, unknown attributes, empty attributes, arrays of stored models, hashes of stored models, one of, alternatives, defining custom types, and disabling parent tracking.
The change tracking trap with arrays of stored models
The usage notes are where the sharp edges are, and the one about change tracking is the one that will bite.
Assigned attributes must be a String, a Hash, an Array of Hashes, or a StoreModel. If the attributes come from a controller, ActionController::Parameters has to be converted as needed.
More subtly, any changes made to a StoreModel instance require the attribute to be flagged as dirty, either by reassignment, for example self.my_stored_models = my_stored_models.map(&:as_json), or by calling will_change!, for example self.my_stored_models_will_change!.
The reason is inherent in the design. The stored model is a Ruby object in memory, and the column is a blob of JSON in the row. Mutating the object in place changes nothing in the row, and ActiveRecord has no way to know, so the change has to be announced. A single object assigned directly to an attribute is usually replaced rather than mutated in place, which is why the warning is attached to collections.
There is a second note in the same list about the ActiveModel behaviour, and it is about parent tracking. Since a stored model is meant to know its parent, the documentation includes a page on disabling parent assignment, which means the default behaviour of the gem does assign the parent and that is a deliberate choice rather than an accident.
Nested attributes and the form builder
Mixing StoreModel::NestedAttributes into a model lets you use accepts_nested_attributes_for in the same way as ActiveRecord, which is where the gem's value becomes obvious in a Rails form.
class Supplier < ActiveRecord::Base
include StoreModel::NestedAttributes
has_many :bicycles, dependent: :destroy
attribute :products, Product.to_array_type
accepts_nested_attributes_for :bicycles, :products, allow_destroy: true
endThe array form of the type is to_array_type, and it is the counterpart of to_type for a single stored model.
With that in place the Rails form builder does its normal work, because the nested attributes integration makes the collection look like an association to the form helpers.
<%= form_with model: @supplier do |form| %>
<%= form.fields_for :products do |product_fields| %>
<%= product_fields.text_field :name %>
<% end %>
<% end %>The generated input is an ordinary Rails nested attribute field, named supplier[products_attributes][0][name]. The controller side is likewise ordinary, with the supplier created from the parameters through from_value.
def create
@supplier = Supplier.from_value(supplier_params)
@supplier.save
end
private
def supplier_params
params.require(:supplier).permit(products_attributes: [:name])
endSo the end to end path is a normal Rails form, normal permitted parameters, and a save that validates the stored models on the way.
ActiveAdmin compatibility is a flag, not a patch
If you use ActiveAdmin, the README tells you to enable a compatibility mode so that the has_many form helper works with StoreModel attributes.
# config/initializers/store_model.rb
StoreModel.config.active_admin_compatibility = trueThat is the entire integration, one line in an initializer. The example that follows shows the full combination, with a Supplier class that is a StoreModel, a Product that is an ActiveRecord model including StoreModel::NestedAttributes with an attribute of suppliers declared as Supplier.to_array_type, accepts_nested_attributes_for with allow_destroy, and an ActiveAdmin registration that permits the nested parameters and renders a has_many block for the suppliers.
ActiveAdmin.register Product do
permit_params suppliers_attributes: [:title, :address, :_destroy]
form do |f|
f.has_many :suppliers, allow_destroy: true do |s|
s.input :title
s.input :address
end
f.actions
end
endThe fact that this needs a flag at all is informative. The has_many form helper in ActiveAdmin expects an ActiveRecord association, and a StoreModel attribute is not one, so the flag tells the gem to bridge the gap. It is a compatibility shim with a switch, which is a good trade, and it is also a reminder that the ecosystem around ActiveRecord has conventions that a stored model does not satisfy natively.
What StoreModel is not, and what the alternative is
The boundary of the approach is worth being blunt about, because it is easy to reach for the gem in a place where it does not help.
StoreModel does not make the JSON column queryable. If the data inside that column needs to be filtered, sorted, joined or aggregated, a JSON column makes that awkward, and wrapping the column in a class does not change that. The rule of thumb is that StoreModel is for structured data you read and write as a whole, and a real table is for data you query.
The alternative is the one the documentation itself has a page for, since alternatives appear in the documentation index. The practical alternative is a normalised table with its own ActiveRecord model, which gives you real columns, real indexes, database level constraints and normal association semantics, at the cost of a migration and a less flexible schema.
The choice turns on how the data is used. A configuration blob that belongs to a product and is always loaded with the product is a good fit for StoreModel. A set of records that will be listed, filtered and aggregated is not.
On maintenance, the project is a small gem with a CHANGELOG.md, a Rubocop configuration, a Coveralls configuration, a spec directory, a benchmark directory, a Rakefile and a gemspec, with several gemfiles for testing against different dependency sets. There are no tagged releases in the repository, so the gem version on rubygems is the version to track, and the last push was on 2026-09-06.
Editorial conclusion
Adopt StoreModel if your Rails models have jsonb columns that have grown validation rules, enums or nested structures, because the gem gives those fields a class without a migration and without a join table. Do not adopt it for data that needs to be queried, indexed or constrained by the database, because a JSON column makes that awkward whatever class you put in front of it. Before you use it on an array of stored models, read the usage note on change tracking, since mutating a stored model in place does not mark the attribute dirty on its own.
Frequently asked questions
How do I use StoreModel with a JSON column in Rails?
Define a class that includes StoreModel::Model and declares its attributes with the Rails Attributes API, then register it on the ActiveRecord model with attribute :configuration, Configuration.to_type. The column is read and written through that class.
Does StoreModel support validations and enums?
Yes. The README says validations, enums and nested attributes work very similarly to the APIs provided by Rails, with an example using an enum on status with a default of active and a presence validation on both the model and status attributes.
Why are my changes to an array of stored models not saved?
Any changes made to a StoreModel instance require the attribute to be flagged as dirty, either by reassigning it, for example self.my_stored_models = my_stored_models.map(&:as_json), or by calling will_change! on the attribute. The README lists this in the usage notes.
How do I make ActiveAdmin work with StoreModel attributes?
Set StoreModel.config.active_admin_compatibility = true in an initializer, for example config/initializers/store_model.rb, so that the has_many form helper works with the stored models, and permit the nested attributes in the ActiveAdmin registration.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/dmitrytsepelev-store-model)