With Rails’s ActiveRecord and presumably other object-relational mappers, it is easy to use persistent, database-backed objects as if they were just there. Create new objects, navigate along associations, just like that. This picture breaks down however, when it comes time to ensure those in-memory changes are saved back to the database.
In general, in a single unit of work, we only want to read an object once from the database and write changes to that object, if any, back to the database at most once.
The “reading once” part is mostly handled by an identity map. Such a map does not prevent multiple reads of the same object through conditional queries, but it does ensure that each persistent object has a single in-memory representation and it does avoid reading the same object twice when the object is accessed by its id (primary key).
The writing part is harder, because a changed or new object might not be valid by the constraints imposed by the application or the database. In effect, writing/saving must be done explicitly in order to react to the possible error conditions. In a lot of common cases this fits in nicely with the business logic.
Say your application provides the functionality to send a message. It is part of your business logic to check that the user has indeed entered a valid message, one having some text for the body. So when you write code like this
class MessagesController < ApplicationController def new @message = Message.create(message_params) if @message.errors.empty? ... else ... end end end
you are really performing a validity check on the message with the side-effect that a valid message is written to the database.
It is a somewhat different case when objects are changed not directly by the user, but as a consequence of another user action. Say we want to mark a message as read when a user has seen it:
class MessagesController < ApplicationController def show @message = Message.find(params[:id]) @message.mark_as_read end end
Now we are coming to the heart of the matter: How do we ensure that the state change (unread -> read) of our message is saved? Maybe like this:
class Message def mark_as_read self.state = 'read' save! end end
That looks fine by itself, but what if we want to make another change to the same object, like this
class MessagesController < ApplicationController def show @message = Message.find(params[:id]) @message.mark_as_read @message.register_last_reader(current_user) end end
and
class Message def register_last_reader(user) self.last_reader = user save! end end
Note that each method by itself ensures that changes are written to the database. As a result, we can compose these methods easily without any further concern that the changes they effect have to be written explicitly to the database.
Unfortunately, there is a drawback. The price we pay is that the same object is written to the database twice because each change is saved individually.
How about not saving the message in mark_as_read
and register_last_reader
and instead doing it in the controller action?
class MessagesController < ApplicationController def show @message = Message.find(params[:id]) @message.mark_as_read @message.register_last_reader(current_user) @message.save! end end
This works, needs only a single database write -- and results in less compositional, more risky code because now mark_as_read
and register_last_reader
implicitly shift responsibility to their calling context.
Now to the denouement. I don't have a handy solution that satisfies all constraints, but I do have a code snippet that helps make the second approach less risky.
This version is for Mongoid, but the idea is applicable to other ORMs that use an identity map. And importantly, the identity map must be enabled for this to work.
What It Does and Why
The after_filter logs and raises an exception if the identity map contains any objects that should have been saved. In particular, these are the objects that have changes to their attributes, excluding non-essential technical attributes, and where no failed attempt has been made to save them explicitly. We don't care for objects with errors, because we assume that such an object is explicitly handled. We do care about the forgotten objects, changed but never saved.