Project

mediate

0.0
No release in over a year
Simple mediator implementation
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
 Dependencies

Runtime

 Project Readme

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
  • Usage
    • Requests
      • Implicit handler declaration
      • Request polymorphism
      • Pre- and post-request behaviors
    • Notifications
    • Error handlers
    • Testing
      • Testing implicit request handlers
    • Using with Rails
      • Nest request handler definitions within request classes
      • Configure Rails to eager load other handlers in non-production environments
  • Development
  • Contributing
  • License

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.