Skip to content

Instantly share code, notes, and snippets.

@jdickey
Created August 22, 2018 18:51
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jdickey/168aa1f6b9f2b66716ea203387ea357b to your computer and use it in GitHub Desktop.
Save jdickey/168aa1f6b9f2b66716ea203387ea357b to your computer and use it in GitHub Desktop.
Interactors, Action Classes, and Errors; Oh, My!

Interactors, Action Classes, and Errors; Oh, My!

Contents

Why Are We Here?

Whee! That was a white-knuckle, E-ticket ride! Not necessarily fun, mind you; going around ever-smaller circles at ever-higher speeds has Unpleasant Side Effects.

But we now have a clearer understanding of Hanami's approach to Clean Architecture and, more specifically, the somewhat nuanced stances that controller actions and Interactors take with regard to data validation, both in terms of formatting and with respect to business logic.

Hanami Controller-Action Parameter Data Validation

The basics of data-format validation have been understood from the beginning; after all, it's largely based on dry-validation, which we've used previously. For controller actions, a params block defines required and/or optional parameter names, data types, and static format/value constraints. These can be made as subtle and complex as needed via dry-validation's high-level rules, with the limitation that rules can only access parameter data; you can't, for example, have rules that depend on the state of data retrieved from a Repository.

But controller actions in well-crafted Clean Architecture applications shouldn't know anything about Repositories or business logic anyway; their role is to serve as HTTP endpoints (i.e., part of the delivery mechanism rather than the logic of the "real app"). They receive data from parameters, cookies, environment variables, and so on; smoke-test that data (e.g, via params); send it off to something that does understand the relevant business logic; and, based on the results reported by that logic, sets flash messages, redirects, and so on.

New Kids on the Block: Interactors

Versions of Hanami prior to 1.2.0 provided no "in-the-box" packaging for that business logic separately from delivery-mechanism code. While it's trivially simple to drop in third-party Gems such as ActiveInteraction, Trailblazer Operations, or so on, or "just roll your own". Many apps didn't bother, and just left that business logic in the controller.

With Interactors in Hanami 1.2.0, there's even less excuse to be that sloppy; it's now practical to enforce sensible boundaries using the as-shipped framework. As with any new feature, however, there are a couple of rough spots to trip the unwary or, at least, make the overall experience less smooth than it might have been.

Error Data

Controller action classes have an #errors method that returns a reference to a Hanami::Action::Params::Errors instance. The #add method (very well-documented in the code) is what's used to add error messages pertaining to an attribute within the params Hash-like object. To cite the code comments' description: given a params schema with a required :book schema (Hash) containing an :isbn string, one can add errors related to that ISBN; e.g., params.errors.add(:book, :isbn, 'is not unique'). But errors mentioning attributes not defined in params raise an ArgumentError; e.g, params.errors.add(:book, :title, 'is too short'). The successfully-added error can later be retrieved using Hash notation: params.errors[:book][:isbn]. The default representation of an Errors instance is as though it were a Hash: {:book => { :isbn => ['is not unique']}}. Note that the presented hash value is an array; more than one message can be associated with any given parameter. (Note also that the Error class has an #error_messages method that returns an array of human-readable strings, e.g, ['Book Isbn is not unique']. We'll come back to that later.)

Interactor classes don't have a Hanami::Action::Params instance; they receive their params as a simple Hash (or something that can be treated as one) passed to their #call emthod. This implies, to the hasty reader of the documentation, that this is merely a transplant of logic that could have gone into the controller action. Incoming parameters obviously should be treated with suspicion until proven otherwise; surely one would do that validation and error-checking within (or in logic called by) #call itself?

#call Me Only If You're Valid

No; that wouldn't be very "clean" architecture, would it, even though that's exactly how you'd do it in almost any framework in almost any language you've ever touched. Interactor classes also have an optional, private #valid? method; return false from #valid?, and the Interactor will report failure without ever calling #call at all. Our impatient dev might at first think that this is simply for format validation, akin to what the params block is used for in controller actions. Again, no, it's not (or, it doesn't have to be) that simple. You should use #valid? to perform both data-format validation and business-logic validation. "How do we do that," you ask. "Earlier, when discussing dry-validation, you mentioned that it's limited only to what can be expressed with reference to the parameters themselves." That's true, for dry-validation and hence, apparently, for params in controller action classes. However, with Interactors you can do things like define an #initialize method which you use to inject dependencies, such as a Repository. Since #valid? is an instance method, #initialize has already been called and any instance variables it defined are available in #valid?.

But what do you do if you find errors? In Interactors, you can call an #error method, which adds a string to an internally-maintained array that is passed back as part of a Result instance. (There is also an #error! method, which adds the specified string to the messages array and terminates execution of the Interactor code.) That array of error-message strings isn't immediately useful to your controller action, however; remember, it has a more structured, Hash-like errors mechanism; useful for associating error messages with form fields. (The controller action's errors object also has an #error_messages method, which returns strings based on the entries in the errors hash.)

Two Places At Once?

Does this mean that the controller instance needs to parse the error strings handed back by the Interactor's Result and build its own errors object to match? That's not very DRY. Until a (likely near-)future release of Hanami introduces code and conventions to deal with that, we can define our own.

New Methods on Interactor Support Module

By convention, our form-data-using Interactors must include a new Conversagence::InteractorMessages module. This module includes Hanami::Interactor if not already included, and then defines the following methods.

Important Note: These methods are intended to be called from an Interactor's #valid? method (or code that it calls). Invalid data must never reach the #call method; if #valid? returns false, then #call will not be called.

add_error

  • Access: Private
  • Used by: Client Interactor code
  • Parameters: *args (Array)
  • Return value: Not relevant

Internally, args is destructured as *keys, key, message; parallelling the earlier example, a call to add_error(:book, :isbn, 'is not unique') on an otherwise-empty error set would be representable as {:book => { isbn => ['is not unique'] }}. That is, keys would describe a set of nested Hash keys, with the innermost Hash containing key as a key, referencing an array to which the message is added.

This method adds error-message data to an internal data structure, but does not interrupt or otherwise affect the execution of the Interactor. See, by contrast, #add_error!, below

add_error!

  • Access: Private
  • Used by: Client Interactor code
  • Parameters: *args (Array)
  • Return value: Not relevant

Stores error-message data identically to the description of #add_error above, followed by a call to transfer_errors! (see below), which will have the effect of calling the Hanami Interactor method #error for message strings built from the data passed to any previous call to #add_error, followed by a call to #error! for the current error's equivalent message string. This will have the effect of immediately terminating execution of Interactor code, with the Result being reported as a failure.

transfer_errors

  • Access: Private
  • Used by: Internal code
  • Parameters: None
  • Return value: Not relevant

Has the effect of calling the Hanami Interactor method #error with string representations of each error added via #add_error or #add_error! (above). For example, calling add_error(:book, :isbn, 'is not unique') followed by transfer_errors would be analogous to calling error('Book Isbn is not unique'].Compare #transfer_errors!, below.

transfer_errors!

  • Access: Private
  • Used by: Internal code
  • Parameters: None
  • Return value: Not relevant

Similarly to #transfer_errors, above, has the effect of calling the Hanami Interactor method #error with string representations of each error added via #add_error or #add_error! (above) except for the last such message, which is added to the Interactor's error list via #error!. This will have the effect of immediately terminating execution of Interactor code, with the Result being reported as a failure.

Note that a call to #transfer_errors! when no errors have been added through #add_error or #add_error! is not supported and will fail.

Updating Controller-Action Error Data

After our "enhanced" Interactors' #valid? method completes validation, with any errors noted via calls to #add_error or #add_error!, then it must call the new #reload_errors method. This method takes as its only parameter the controller action's params object. That object's errors object is cleared, and the structured error data is added to it. This is the only way to tell the higher-level Hanami controller logic that errors have been detected and the form data is invalid. Error messages can then be displayed appropriately as the user gets another go at filling in the form.

Closing

If anyone else has any better ideas on how to reconcile Hanami 1.2.0's Interactor error messages with controller-action error data, in a well-structured, DRY, coherent manner, please submit a PR. Until then, and until some Hanami future version provides an official mechanism, this should get the job done.

The last few days have been, inter alia, a fascinating deep dive into Hanami's controller and Interactor architecture, and their relation to and effects from the Hanami team's understanding of Clean Architecture. The canonical description of Clean Architecture has long been Uncle Bob's 2012 post, which follows on from his widely-known Ruby Midwest 2011 keynote, Architecture: The Lost Years. Piotr Solnica, the creator of rom-rb, the co-founder of the dry-rb project, and a profound influence on several of the Hanami core team members, did a presentation some time back called Architecture: The Reclaimed Years (slides here).

Interactors are a good idea; that shouldn't be in dispute, now that there are so many variations on the pattern implemented not just in Ruby, but in other languages where separating the implementation details from the business logic is correctly prioritised. There are, as noted previously, other good implementations of the pattern in Ruby. Any of these, hoewver, would require some amount of work to make work nicely with Hanami action controllers, as with the equivalents in Rails or any other framework. Hanami Interactors' design philosophy of having a single-purpose #valid? method that guarantees the validity of incoming parameter data before the object's #call method is invoked with those parameters is a boon. In this scheme, if you're doing error-checking in your main code, You're Doing It Wrong. This, along with a reasonably well-structured validation and error-reporting system from within the #valid? method, will make maintenance of Interactors, and development of new ones, far more painless than any equivalent development activity yet. Yes, we've gone through a significant amount of pain and effort to get early Interactors close enough to "right". In 39 years in this craft, the next major architectural feature I encounter where this is not the case will be the very first.

@jdickey
Copy link
Author

jdickey commented Aug 23, 2018

So, basically, from the standpoint of the developer of an Interactor in this setup:

  1. The Interactor class (e.g., ChangeUserPassword must include Conversagence::InteractorMessages. This can either be subsequent to or in place of include Hanami::Interactor, as our module will ensure that Hanami::Interactor is included;
  2. Instead of calling #error or #error! to add a string message, the dev calls #add_error or #add_error! instead. Continuing the example used in the Hanami docs, error("ISBN is not unique") would instead be add_error(:book, :isbn, "is not unique").

The remaining process for developing and using Interactors is exactly as documented. "Behind the scenes", Hanami::Interactor::Interface#_call is overridden by the Conversagence::InteractorMessages module to catch :fail and call #transfer_errors! before yielding. The new method, after its catch :fail block, would call transfer_errors if the pre-yield call had not occurred (which would be the case if validation code had made any calls to #add_error! or #error!), followed by #_prepare!, as is done by the existing #_call method. This has the effect of guaranteeing that #transfer_errors! and/or #transfer_errors are called, and the Interactor's string error-message array set up correctly, before calling #_prepare!, which loads that error-message array into the Result object.

NOTE that Hanami::Interactor::Interface#_call is marked as being part of a private API, which means that the Hanami team can change it in any release going forward. However, given that this is actually the second version of the interface (the first is preserved as LegacyInterface) in a post-1.0 release, we feel that the risk of upstream change between major (x.0.0) releases is low enough to accept making our needed change here, rather than require the client Interactor developer to remember to call #transfer_errors/#transfer_errors! explicitly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment