Direct answer
There is no documented core pattern in Backbone 1.x that prevents direct access to model.attributes without overriding the Model prototype, subclassing, or wrapping the instance. Backbone intentionally exposes the hash and relies on developer convention rather than language-level privacy.
Confirmed behavior vs. likely rationale
Confirmed: model.attributes is public. Writing to it directly—e.g., model.attributes.name = 'b'—bypasses set(), so change, change:attr, and validate() do not run. model.changed and model.previousAttributes() remain out of sync until the next managed set() call.
Likely explanation: Backbone trades encapsulation for flexibility and performance. The library keeps the core lightweight and assumes disciplined use of get() and set().
Practical enforcement without prototype changes
- Convention and code review. Treat
attributes as read-only outside the model. Use get() for reads and set() for writes.
- Lint rules. Add an ESLint
no-restricted-properties rule to block .attributes member expressions in application code.
- Wrapper facade. Expose a thin object that offers only
get, set, and toJSON, hiding the underlying model instance from consumers.
Approaches to avoid
Object.freeze() on attributes. This breaks internal assignments during set(), clear(), and unset().
Proxy around attributes. This is undocumented in Backbone 1.x. It can cause subtle failures in toJSON(), save(), and fetch(), and it is unavailable in older runtimes.
Verification
To confirm the bypass in your codebase, create a model with {id: 1, name: 'a'}, assign model.attributes.name = 'b', and observe that no change event fires. Then call model.set('name', 'b') and verify the event fires. Inspect model.changed after the direct write to confirm it is empty.
If you need to support legacy browsers or Backbone versions below 1.0, mention that in your environment details—it changes whether a Proxy-based guard is even worth prototyping.