POV: Rediscovering the Joy of Design
Thanks to Rediscovering the Joy of Design, this was truly interesting week for me. Those articles by Adam Hawkins made me rethink my application design, which lead to full scale refactoring of an existing app I was working on. Proposed solutions solved so many problems, and removed all those smelly pieces of code that I didn't know how to make right. Not to mention that my test suite now runs at the speed of light: 207 examples in a fraction of a second, and yes, most of them are integration tests.
However, there are few things that I have approached differently, and which I would like to write about here.
Form objects serve single purpose: to accept and coerce user input. These objects need to translate what user meant by typing something in. For example, user may type in 2014-01-01. That is clearly a date, but in the context of due_date user probably meant: "by the end of that day". So, the form should coerce it to TimeWithZone object and shift it to end of day. Luckily, this is quite easy to achieve with Virtus custom attributes.
class Form include Virtus.model attribute :due_date, Attribute::TimeWithZoneEndOfDay end
Since form objects are very simple, and highly contextual, I like to define them within a controller that uses them.
class TasksController class Form include Virtus.model # ... end def new @form = Form.new end end
There was a debate on whether forms should handle input validation. After all, that is another responsibility. I wanted to keep my forms simple, so I decided that I will keep them validation-free. Mutations just made this possible for me.
Mutations are changes of application state. Adam refers to these as "Use Cases". One thing I didn't like about Adam's approach is passing the form object to use case. This makes reusing use cases in different places harder (e.g. both in web application and its api). For example, in web application user will provide date string for due_date, but in api, we may require user to pass numeric timestamp. That means we'll have two different form objects, and our use case will need to know how to handle both.
I took another approach. I went with awesome mutations gem. It allows us to specify required and optional parameters. It also supports input validation. So, instead of passing form object as a whole to mutation, we'll just pass its attributes.
# ... def create @form = Form.new params[:task] Tasks::Create.run! @form.attributes, current_user: current_user end # ...
When mutation is ran (with !), it will raise an exception with summary of invalid inputs. We can rescue from this exception and present validation errors in our web app, or just respond with 400 in our api.
After lots of thinking (and tons of experiments with different designs), i decided that natural place for authorization logic is within the mutation itself (like Adam suggested). There was one extra requirement: I wanted to be able to check whether specific mutation can be performed by given user upfront (e.g. whether or not to show "edit" button in the UI, or even render the edit page). In order to do so, I simply moved the authorize! call to be class method:
# ... def execute task = TaskRepo.find(id) self.class.authorize!(current_user, task) task.attributes = inputs.slice(:title, :due_time) task.save end def self.authorize!(current_user, task) current_user.project_admin?(task.project_id) end # ...
Now, we can conditionally show the Edit link:
# ... helper method def mutable?(mutation, *args) mutation.authorize!(current_user, *args) true rescue PermissionDeniedError false end # ... in views <% if mutable?(Tasks::Update, task) %> <%= link_to 'Edit', '#' %> <% end %> # ...
How about uniqueness validation? Mutations::Command provides validate method that we can use to perform custom validations. The code is simple, and it could look like this:
# ... def validate add_error(:email, :taken, "Email is taken") if email_taken? end def email_taken? # query repo to find out whether email is already taken end # ...
The other option would be to implement this kind of check on repo level:
class UserRepo def save # raise error if email is taken super end end
and then to rescue from exception within your mutation to add_error and raise Mutations::ValidationException. Which approach to take really depends on how important the uniqueness is and whether multiple use cases will need to handle this situation.