Allow ActiveModel::Attributes to be declared without a writer

Hi all. A small proposal for ActiveModel::Attributes. I’d rather agree on the API here than open a PR that guesses wrong.

The gap

There’s no way to declare an Active Model attribute that can’t be reassigned after construction. Every attribute generates a public writer.

That gets in the way of building immutable POROs on top of Active Model, which is otherwise the natural foundation for them: you get coercion, defaults, validations and errors for free.

The workaround today is to privatize the generated writer by hand:

ruby

class DateRange
  include ActiveModel::API
  include ActiveModel::Attributes

  attribute :from, :date
  attribute :to,   :date
  private :from=, :to=      # needed for every attribute, in every class

  validates :from, :to, presence: true
end

It works, but it’s easy to forget, has to be repeated per attribute, and every library that wants immutable inputs reimplements it.

Freezing the object instead isn’t an option: ActiveModel::Validations#errors memoizes @errors on the instance, so valid? raises FrozenError on a frozen receiver. That’s the same root cause as #1513 for composed_of, which is still unresolved. A private writer is the practical middle ground: the object stays fully usable with the rest of Active Model, but its attributes aren’t rebindable through the public API.

Ruby 3.2’s Data made immutable value objects a first-class idiom, and they compose with none of this precisely because they’re frozen. Active Model is where the gap shows.

Prior art

Active Record has had attr_readonly for years, and #46105 hardened it so assignment raises instead of failing silently. Active Model has no equivalent. It reads like the same parity gap #53886 closed for #[] and #[]=.

Possible APIs

  1. attribute :from, :date, writer: false Describes exactly what happens, and doesn’t borrow attr_readonly’s meaning.
  2. attribute :from, :date, readonly: true Matches Active Record’s vocabulary, but attr_readonly means “excluded from UPDATE”, which is a different thing.
  3. attr_readonly :from on Active Model Maximum symmetry, maximum semantic collision.

I lean towards (1) for that reason, but I don’t hold it strongly and would rather build whichever one you’d accept.

One detail that applies to all three: ActiveModel::API#initialize and assign_attributes assign through public writers, so they’d need to go through __send__ (or the attribute set) for these attributes.

Happy to open the PR with tests, a CHANGELOG entry and a note in the Active Model Basics guide once there’s a direction.

1 Like

I really like this, and agree that #1 is the best path forward. The more things that can move (or be approximated) from ActiveRecord to ActiveModel, the better. I find that so many things I once would have made a whole-ass table-based AR model from years ago (just to get one or more of the built-in goodies from AR) are much better suited to something smaller and composed. (I don’t have any authority to say this, just putting my many years in Rails behind your idea.)

Thanks Walter. “Approximated from Active Record to Active Model” is a better way to put it than what I wrote, and it’s the actual shape of the request: the goodies are already there, the declaration isn’t. The table-backed-model-just-for-the-goodies point is the part I’d like to sharpen. If anyone else has reached for a real AR model, or hand-rolled `private :foo=`, purely to get typed, non-rebindable attributes, I’d like to collect those cases here. Concrete examples seem more useful than my single one. Still happy to write the patch for whichever spelling core prefers, with `writer: false` as the working assumption.