The Ruby on Rails and ClojureScript experts

May 4, 2020

The ActiveInteraction gem is super useful, however we like to use dry-validation for input argument validation.

Below is a sketch for how to integrate ActiveInteraction, dry-validation, and dry-container to build interactions with the following features:

  • Implement the Command pattern in Ruby on Rails to make processes and interactions first class citizens, keeping Controllers and Models very simple.
  • Type cast and validate all interaction inputs.
  • Share and compose schema validations between interactions.
  • Handle outcomes and error reporting.
  • Compose interactions to build complex workflows.
  • Interactions are invoked in the same way from controllers, background jobs, production consoles, or other interactions.
  • Support dependency injection to facilitate testing.

Data vs. Behavior

At a high level, we distinguish these two concerns to help us structure our Rails application:

Data

All data related concerns live in models:

  • Attributes (DB columns)
  • Association declarations
  • Some validations
  • ActiveRecord scopes
  • Method delegation
  • NOTE: There should be no callbacks in models. They fall clearly in the domain of behavior.

and contracts:

  • Schema specification for a given data structure.
  • Higher level (business logic) validation rules.

NOTE: contracts can also live directly inside an interaction if they are not shared with other interactions.

Behavior

All behavior related concerns live in interactions:

  • Any mutation of database records.
  • Multi-step and nested processes.
  • Interactions with external services.

Workspaces

Another architectural choice we make is to group all behavior and ui related code into workspaces. Workspaces should be aligned with stable concepts in the application domain, e.g., user roles, departments, or stable software architectural concepts. Some examples:

  • authentication (log-in, password resets, session management)
  • admin (administrator tasks like user management, lookup values management)
  • system (hardware reports, background jobs UI)
  • finance (UI for finance department)
  • anonymous (Public faces that don’t require authentication)
  • teachers (UI for users with a teacher role)

Installation

Add these gems to your Gemfile:

gem "active_interaction"
gem "dry-container"
gem "dry-validation"

Add these folders to your Rails application:

* app
    * contracts
    * interactions

Usage

Below is an example for an interaction used to create issues. The interaction is invoked from a controller:

# app/controllers/comms/issues_controller.rb
  ...

  def create
    iao = Comms::Issues::Create.run(
      args: params
              .fetch(:issue, {})
              .to_unsafe_h
              .merge(reporter_id: current_user.id),
    )
    respond_to do |format|
      format.html {
        if iao.valid?
          @issue = iao.result
          redirect_to comms_issue_path(@issue)
        else
          @issue = iao
          render(:new)
        end
      }
    end
  end

  ...

This is what the interaction looks like:

# apps/interactions/comms/issues/create.rb
module Comms
  module Issues
    # Creates an Issue
    class Create < ActiveInteraction::Base

      # See further down for implementation of this module.
      include ActiveInteractionWithDryValidation

      # Contract for validating args
      # NOTE: This could also inherit from a shared contract like so:
      #     class ArgsContract < Comms::Issues::CreateContract; end
      #     (shared contract is at app/contracts/comms/issues/create_contract.rb)
      class ArgsContract < Dry::Validation::Contract

        params do
          required(:title).filled(:str?)
          optional(:description).maybe(:str?)
          required(:reporter_id).value(format?: UUID_REGEX)
          optional(:assignee_id).maybe(format?: UUID_REGEX)
          required(:issue_type).value(:str?)
        end

      end

      # @return [Issue] the newly created Issue
      def execute
        issue = Issue.new(args.to_h)
        if issue.save
          # Add a comment to the issue timeline
          compose(
            Comms::IssueComments::Create,
            args: {
              issue_id: issue.id,
              description: "reported this issue",
              kind: "update",
              user_id: args.reporter_id,
            },
          )
        else
          errors.merge!(issue.errors)
        end
        issue
      end

      # To help Rails form builders when using this interaction as a form object.
      def to_model
        Issue.new
      end

    end
  end
end

Differences between this approach and regular ActiveInteractions:

  • Inputs are validated via class ArgsContract rather than input filters.
  • When refering to inputs, use args.to_h/args.l_var instead of inputs/local variables.

Conventions for interactions

  • We pass it the same arguments we would pass to a Rails controller (Hash with values of basic Ruby types only). We do this so that the interaction can be invoked easily, no matter the context since all inputs are of basic types. That means we pass user_id instead of a User record.
  • Naming:
    • When an interaction operates on an ActiveRecord class, we use the following naming:
      • Workspace: The workspace the interaction belongs to. In the example this is Comms for “communications”.
      • ActiveRecord class pluralized. In the example we use Issues. We pluralize to avoid naming conflicts with the model class.
      • Action verb: A verb that describes what this interaction does. In the example Create an issue.
    • When an interaction wraps an external service, we use the following naming:
      • Workspace: For example Authentication
      • The external service, e.g., Sso
      • Action verb: For example Authenticate

Dependency injection

To facilitate testing we use dependency injection via dry-container. This allows us to stub/mock dependencies that are unsuitable for testing:

Usage

Set up an app-wide dependencies container and register all dependencies you want to inject:

# config/initializers/deps_container.rb

# Registers services in app wide container for dependency injection
class DepsContainer
  extend Dry::Container::Mixin
end

# Git gem to interact with git repositories
DepsContainer.register(:git, Git)

Resolve the dependencies as needed for regular use:

# apps/interactions/nw_app_structure/nw_patches/apply.rb
  ...

  def commit_changes_to_git
    git_repo = DepsContainer[:git].open(Rails.root)
    git_repo.add(@file_to_commit)
    git_repo.commit("<the git commit message>")
  end

  ...

When testing this interaction, you can stub/mock the git dependency to avoid creation of unwanted git commits in the app’s git repo:

# test/interactions/nw_app_structure/nw_patches/apply_test.rb
require 'dry/container/stub'

# before stub you need to enable stubs for specific container
DepsContainer.enable_stubs!
# Replace the `Git` class with your own mock class for testing:
DepsContainer.stub(:git, MockGit)

# Then exercise the interaction and verify that mocked methods on git were called with expected arguments...

More info on stubbing/mocking with Minitest.

How this works

  • ActiveInteraction provides very capable interactions, however the built in input validation is somewhat lacking.
  • dry-validation provides very powerful tools to validate interaction inputs.

To combine the two, we use this snippet of code:

module ActiveInteractionWithDryValidation

  extend ActiveSupport::Concern

  # Represents the inputs provided to the interaction after they were validated by dry-validation.
  class Args

    # @param args [Hash] (@see ArgsContract in calling Interaction)
    # @param args_contract_class [Class] the contract class
    def initialize(args, args_contract_class)
      @args = args
      @args_contract_class = args_contract_class
      # Create attr_accessor for each top level schema key and assign the value
      @args_contract_class.schema.key_map.each { |key|
        self.class.class_eval { attr_accessor key.name }
        send("#{key.name}=", @args[key.name.to_sym])
      }
    end

    def to_h
      @args
    end

    def validate
      @args_contract_class.new.call(@args)
    end

  end

  included do
    # Use ActiveInteraction type casting to convert `inputs` into `args` (object of class Args).
    object :args, class: Args, converter: ->(args) { Args.new(args, self::ArgsContract) }
    # Validate args using dry-validation
    validate :validate_args
  end

  def validate_args
    r = args.validate
    return true if r.success?

    # Convert dry-validation errors to ActiveInteraction/ActiveModel ones:
    # * Convert `nil` keys to `:base`. dry-validation uses `nil`, whereas ActiveInteraction expects `:base`
    # * Flatten nested error keys:
    #   { issue_args: { title: ["is missing"]}} => { issue_args_title: ["is missing"] }
    # * Add an error for each message under a given key.
    compatible_errors = {}
    r.errors.to_h.each { |k, messages_or_hash|
      effective_key = k || :base
      extract_nested_messages(compatible_errors, [effective_key], messages_or_hash)
    }
    compatible_errors.each { |error_key, messages|
      messages.each { |message| errors.add(error_key, message) }
    }
  end

  # @param messages_collector [Hash] will be mutated in place with new messages as we find them
  # @param key_stack [Array<String, Symbol>] the stack of keys we are processing
  # @param value [Array, Hash] if it's an Array, extract messages. If it's a hash, process recursively
  def extract_nested_messages(messages_collector, key_stack, value)
    case value
    when Hash
      # Recursively process
      value.each { |k, v| extract_nested_messages(messages_collector, key_stack << k, v) }
    when Array
      # Add value as message to current key_stack
      effective_key = key_stack.map(&:to_s).join("_").to_sym
      messages_collector[effective_key] ||= []
      messages_collector[effective_key].concat(value)
    else
      raise "Handle this: #{key_stack.inspect}, #{value.inspect}"
    end
  end

end