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
attribute :from, :date, writer: falseDescribes exactly what happens, and doesn’t borrowattr_readonly’s meaning.attribute :from, :date, readonly: trueMatches Active Record’s vocabulary, butattr_readonlymeans “excluded from UPDATE”, which is a different thing.attr_readonly :fromon 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.