Mediate

CI Gem Version

A simple mediator implementation for Ruby inspired by Mediatr.

Decouple application components by sending a request through the mediator and receiving a response from a handler, instead of directly calling methods on imported classes.

Supports request/response, notifications (i.e., events), pre- and post-request handler decorators, and error handling.

Installation

Add this to your Gemfile:

gem "mediate"

And run:

bundle

Usage

There are two types of messages that can be sent through the mediator:

  • Requests (Mediate::Request) have exactly one handler (Mediate::RequestHandler), which returns a response.
  • Notifications (Mediate::Notification) are published to zero or more handlers (Mediate::NotificationHandler). Nothing is returned to the caller.

Requests

To define a request, declare a class that inherits from Mediate::Request.

class Ping < Mediate::Request
    attr_reader :message

    def initialize(message)
        @message = message
        super()
    end
end

To register a handler for it, declare a class that inherits from Mediate::RequestHandler, call the class method handles passing the class of requests that it handles, and implement the handle method.

class PingHandler < Mediate::RequestHandler
    handles Ping

    def handle(request)
        "Received: #{request.message}"
    end
end

To send a request, pass it to Mediate.dispatch. The mediator will resolve the registered handler according to the request type and return the result of its handle method.

response = Mediate.dispatch(Ping.new('hello'))
puts response # 'Received: hello'

The only requirement for RequestHandlers, besides implementing the handle method, is that they should have a constructor that can be called without arguments. This applies to all *Handler and *Behavior classes. For example, the following would work because all constructor parameters have default values.

class PingHandler < Mediate::RequestHandler
    handles Ping

    def initialize(service = SomeService.new)
        @service = service
    end

    def handle(request)
        @service.call("Received: #{request.message}")
    end
end

Note that only one handler can be registered for a particular request class; attempting to register another handler for Ping would raise a RequestHandlerAlreadyExistsError.

Implicit handler declaration

For simple handlers, you can skip the explicit RequestHandler declaration above and instead pass a lambda to Request.handle_with.

class Ping < Mediate::Request
    attr_reader :message

    def initialize(message)
        @message = message
        super()
    end
    # This will have the same behavior as the PingHandler declaration above.
    handle_with ->(request) { "Received: #{request.message}" }
end

response = Mediate.dispatch(Ping.new('hello'))
puts response # 'Received: hello'

Behind the scenes, this defines a Ping::Handler class that calls the given lambda in its handle method. For testing purposes, you can get an instance of this handler class by calling Mediate::Request.create_implicit_handler (see Testing implicit request handlers).

Request polymorphism

The mediator resolves handlers by moving up the request's inheritance chain until it finds a registered handler for that class. For example, subclasses of Ping would be handled by PingHandler.

class SubPing < Ping; end
puts Mediate.dispatch(SubPing.new('howdy')) # 'Received: howdy'

Unless we registered a handler for SubPing explicitly.

class SubPing < Ping
    handle_with ->(request) { "Received from SubPing: #{request.message}" }
end
puts Mediate.dispatch(SubPing.new('howdy')) # 'Received from SubPing: howdy'

Pre- and post-request behaviors

For certain cases, you will want code to run before or after a request is handled, e.g., logging, authorization, validation, backwards compatibility, etc. Effectively, these act as decorators for your request handler(s). You can register Mediate::PrerequestBehaviors and Mediate::PostrequestBehaviors for this purpose.

Behaviors will run for any request that is or inherits from the request class registered. For example, if you wanted a behavior to run for every request, you could register it with handles Mediate::Request. Unlike request handlers, multiple behaviors can be registered for the same request class.

class PreLoggingBehavior < Mediate::PrerequestBehavior
    handles Mediate::Request # This will be called before all request handlers

    def initialize(logger = Logger)
        @logger = logger
    end

    def handle(request)
        @logger.info("Received request: #{request}")
    end
end

class PingValidator < Mediate::PrerequestBehavior
    handles Ping # Will be called before Ping requests or any subclasses of Ping

    def handle(request)
        raise "Ping is missing message" if request.message.nil?
    end
end

class PostLoggingBehavior < Mediate::PostrequestBehavior
    handles Mediate::Request # Will be called after all request handlers

    def initialize(logger = Logger)
        @logger = logger
    end

    def handle(request, result)
        @logger.info("Request: #{request} resulted in #{result}")
    end
end

Notifications

Notifications are messages that can be passed to multiple handlers. To publish a notification, call Mediate.publish(notification). No response is returned from publish.

Define a notification by inheriting from Mediate::Notification.

class PostCreated < Mediate::Notification
    attr_reader :post

    def initialize(post)
        @post = post
    end
end

Declare and register a handler by inheriting from Mediate::NotificationHandler, calling handles with the notification class to handle, and implementing the handle method.

class PostCreatedHandler < Mediate::NotificationHandler
    handles PostCreated

    def handle(notification)
        # do something with PostCreated notification...
    end
end

Like request behaviors, all notification handlers that are registered for a notification class or any of its superclasses will be called when a given notification is published. For example, a handler that handles Mediate::Notification will be called when any notification is published. Handlers will be called in order of inheritance of their registered notifications from subclass to superclass (and in order of registration if the registered notification class is the same).

Error handlers

When a request or notification handler raises a StandardError, the mediator will find all ErrorHandlers that have been registered for that request/notification class (or superclasses) and the exception class (or superclasses).

# This will be called on any StandardError from any request or notification handler
class GlobalErrorHandler < Mediate::ErrorHandler
    handles StandardError, Mediate::Request
    handles StandardError, Mediate::Notification

    # dispatched is the Request or Notification
    def handle(dispatched, exception, state)
        # do something...
    end
end

# This would get called when ActiveRecord::RecordNotFound is raised while handling a QueryRequest
class NotFoundHandler < Mediate::ErrorHandler
    handles ActiveRecord::RecordNotFound, QueryRequest

    def handle(dispatched, exception, state)
        # ...
    end
end

Note that the exception class passed to handles must be StandardError or a subclass of it.

The state parameter of handle is a Mediate::ErrorHandlerState instance that represents whether the exception has been "handled" or not. By calling set_as_handled and optionally passing in a result, all subsequent error handlers will be skipped and the given result will be returned to the caller of dispatch (obviously, if the error was raised from a notification handler, nothing will be returned).

class ValidationErrorHandler < Mediate::ErrorHandler
    handles ActiveRecord::RecordInvalid, Mediate::Request

    def handle(dispatched, exception, state)
        state.set_as_handled(exception.record.errors)
    end
end

Testing

All of the handler and behavior classes described above are just normal Ruby classes. You can instantiate them and call their handle methods to test as you normally would.

Special consideration is only required when testing paths that invoke methods on the mediator itself (e.g., Mediate.dispatch or Mediate.publish), since it is designed to be a singleton. The mediator's registration methods are idempotent (and thread-safe), so re-registering handlers should not cause issues. However, if you want to ensure that you are not sharing state between tests, you can call the Mediate.mediator.reset method in your test setup or clean-up to remove all handler and behavior registrations.

Testing implicit request handlers

How can you test a request handler defined using handle_with and a lambda like the following?

class ExampleRequest < Mediate::Request
    handle_with lambda { |request|
        # ....
    }
end

The handle_with method defines a handler class and registers it with the mediator to handle the containing request class. Mediate::Request provides a convenience method, create_implicit_handler, that creates an instance of this handler class. You can then call handle on that method like normal to test it.

RSpec.describe "ExampleRequestHandler" do
    let(:handler) { ExampleRequest.create_implicit_handler }

    it "returns something" do
        expect(handler.handle(ExampleRequest.new)).to be_truthy
    end
end

Using with Rails

Handler registrations occur within methods called from class definitions. In non-production environments, by default, Rails lazy loads constants. Therefore, if handlers are not explicitly referenced, which is typical, their class definitions will not be loaded and they will not be registered as handlers on the mediator.

There are two things that need to be done to work around this behavior:

Nest request handler definitions within request classes

Since requests will be explicitly referenced in, say, controllers, we can force their handler constants to load with them by nesting those definitions within the request definitions. For example:

class MyRequest < Mediate::Request
  # ...
  class MyRequestHandler < Mediate::RequestHandler
    handles MyRequest

    def handle(request)
      # ...
    end
  end
end

This has the added benefit of colocating a handler with its request, making it easy to find. This is therefore the recommended way to declare requests and their handlers. Implicit handler declarations do this automatically.

Configure Rails to eager load other handlers in non-production environments

For other handlers (pre- and post-request behaviors, error handlers, notification handlers), it is typically either not possible or not practical to nest their declarations within the class definitions they handle. You will have to add configuration to eager load these in environments where global eager loading is turned off. This can be accomplished by adding their file paths to config.eager_load_paths in the relevant environment files, like so:

# config/environments/development.rb

Rails.application.configure do
  # ...
  [
    'app/use_cases/common/**/*.rb',
    'app/use_cases/**/event_handlers/**/*.rb'
  ].each do |path|
    config.eager_load_paths += Dir[path]
    ActiveSupport::Reloader.to_prepare do
      Dir[path].each { |f| require_dependency("#{Dir.pwd}/#{f}") }
    end
  end
end

(In the above, we're assuming, for example, that we have pre- and post-request behaviors and error handlers in app/use_cases/common/ and notification handlers in app/use_cases/**/event_handlers/**/.)

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and the created tag, and push the .gem file to rubygems.org.

Contributing

Bug reports and pull requests are welcome in this repo.

License

The gem is available as open source under the terms of the MIT License.