mediator

package module
v0.0.0-...-851d65b Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 2, 2023 License: MIT Imports: 3 Imported by: 0

README

mediator
build-status go report MIT License coverage docs

mediator

A lightweight implementation of the Mediator Pattern for GoLang, inspired by jbogard's MediatR framework for .net.

Project History

This project was previously known as go-mediator. It has been renamed as mediator for consistency with the package name and because all blugnu projects are golang; the go- prefix was just noise.

At the same time, the project was completely re-written; it now shares little more than the original concept with the previous incarnation.

If you previously imported go-mediator you should update your imports to the renamed module.


The Mediator Pattern

The Mediator is a simple pattern that uses a 3rd-party (the mediator) to facilitate communication between two other parties without either requiring knowledge of each other.

It is a powerful pattern for achieving loosely coupled code.

There are many ways to implement the pattern, from simple func pointers to sophisticated and complex messaging systems; blugnu/mediator sits firmly at the simple end of that spectrum!

Why Use mediator

For code that provides a substantial component of domain behaviour, using mediator provides a consistent mechanism for de-coupling, implementing, calling and mocking those components.

When NOT To Use mediator

Often when testing you may find yourself needing to use a function variable so that you can inject a fake or spy function in order to test higher-level code. mediator is not designed or intended to replace this or similar techniques.

What mediator Is NOT

  • it is not a message queue
  • it is not asynchronous
  • it is not complicated!

How It Works

TL;DR:

Your code registers commands to respond to requests of various types. Commands are then called by passing requests to the mediator; the mediator lookups up the command that handles that request, calls it and returns the result and any error.

In Detail

blugnu/mediator maintains a registry of commands that respond to requests of a specific type. As well as responding to a specific request type, each registered command identifies the result type that it returns to any caller.

There can be only one command registered for any given request type.

Commands are registered during initialising of your application using RegisterCommand, or by establishing mock commands in tests. Command configuration checks are performed when registering commands. The RegisterCommand function tests for an implementation of the ConfigurationChecker interface (CheckConfiguration() function) which is called if present. If configuration checks return an error, this is returned by the RegisterCommand function and the command is not registered.

Registered commands are called indirectly via a generic mediator.Execute[TRequest, TResult] function: the mediator.

The mediator consults the registered commands to identify the command for the request type involved. If no command is registered then a NoCommandForRequestTypeError is returned.

If a command is identified but the caller and the command do not agree on the result type, a ResultTypeError is returned.

If the correct result type is expected, the mediator tests for an implementation of the Validator interface (Validate() function) which is called if present. Any error returned from the Validate() function is wrapped in a ValidationError (if necessary) and returned to the caller.

If there is no Validator interface, or the request is validated successfully, the request is passed to the command and the result and any error from the command then returned to the caller.

All of this takes place synchronously as direct function calls. i.e. if the command panics, the stack will contain a complete path of execution from the caller, thru the mediator to the corresponding command function.



Implementing a Command

  1. (Recommended): Create a Package for Your Command
  2. Declare request, result and command types
  3. (Optional) Implement the ConfigurationChecker interface for the command
  4. (Optional) Implement the Validator interface for the command
  5. Implement the CommandHandler interface for the command
  1. There are numerous advantages to implementing each command in its own package. See Packaged Commands for more details.
  1. Any configuration checks incorporated in the Execute function are performed for every request; performing these checks in a CheckConfiguration() function (implementing the ConfigurationChecker interface) these checks are performed just once, at the time of registering the command. See Command Configuration Checks for more information.
  1. Any request validation is recommended to be performed in a Validate() function (implementing the Validator interface). See Request Validation for more information.
  1. Register the command, e.g.:
    err := mediator.RegisterCommand[myCommand.Request, *myCommand.Result](ctx, &myCommand.Handler{})

Once a command has been registered it cannot be unregistered, i.e. it is not possible to dynamically reconfigure registered commands to respond to requests of a given type with different commands at different times. This is by design. In contrast, mock commands can (and must) be reconfigured during the execution of different tests, and this is possible (see: Testing With Mediator).



Calling a Command Using mediator

The mediator.Execute function accepts a Context, the request to be executed and a pointer to a value of the result type. The function returns the result value and any error from the command.

The result type pointer is not de-referenced by the mediator and does not receive any result.

The pointer is required only as a type-hint for the compiler so that it can infer the types required by the generic Execute function.

It is recommended to use new() to provide a pointer of the required type

example
    rq := myCommand.Request{Id: id}
    rs, err := mediator.Execute(ctx, rq, new(*myCommand.Result))

In the above example, myCommand returns a pointer to a myCommand.Result; new() in this case is used to return a pointer to a pointer.

Commands Returning No Result

For commands that have no result value mediator provides a convenience type for use when implementing and registering commands returning no result, and a variable for use as a type-hint when calling such a command:

    type NoResultType *int
    var NoResult = new(NoResultType)

A command that specifically has no result value is registered with a result type of mediator.NoResultType and, as you would expect, the Execute() function of that command returns mediator.NoResultType.

NoResultType is a pointer so that when implementing the Execute() function for a command returning NoResultType you can return nil.

example
    // Registering a command returning no result
    err := mediator.RegisterCommand[MyRequestType, mediator.NoResultType](ctx, MyCommandHandler{})

    // Implementing the Execute function of a command returning no result
    func (cmd *Handler) Execute(ctx context.Context, req Request) (mediator.NoResultType, error) {
        if err := SomeOperation(); err != nil {
            return nil, err
        }
        return nil, nil
    }

A caller can use either new(mediator.NoResultType) or mediator.NoResult as the result type-hint for the Execute function, discarding the returned result.

example
    rq := deleteFoo.Request{Id: id}

    // these two statements are functionally equivalent
    _, err := mediator.Execute(ctx, rq, mediator.NoResult)
    _, err := mediator.Execute(ctx, rq, new(mediator.NoResultType))


Command Configuration Checks

Before executing any request, a command will typically check the configuration of the command, e.g. to ensure that any required dependencies have been supplied. This incurs the overhead of those configuration checks on every request when they typically only need to be performed once.

To perform these checks only once, a command may implement the ConfigurationChecker interface:

type ConfigurationChecker interface {
    CheckConfiguration(context.Context) (err error)
}

If implemented, the CheckConfiguration function is called when registering the command. If an error is returned from the function then the command registration fails and the error is returned from the RegisterCommand function.



Testing With Mediator

The loose-coupling that can be achieved with a mediator is particularly useful for unit testing.

When unit testing code that calls some command using mediator you are able to mock responses to the request to test the behaviour of your code under a variety of error or result conditions, without having to modify the code under test.

Mock commands

You can implement mock commands for your request as needed, or you can use the mock factories provided by blugnu/mediator; these should be sufficient for most - if not all - common use cases.

The mocks returned by these factories provide an Unregister() method to remove the registration for that command; typically you would defer a call to this Unregister() method immediately after initialising the mock

example
    mock := mediator.MockCommand[myCommand.Request, myCommand.Result]()
    defer mock.Unregister()

The example above illustrates the mock factory that initialises a command that mocks a successful call, returning a zero-value result and nil error.

The factory functions are:

    // Mocks a command returning a zero-value result and nil error
    MockCommand[TRequest, TResult]() *mockcommand[TRequest, TResult]

    // Mocks a command returning a specific result and nil error
    MockCommandResult[TRequest, TResult](result TResult) *mockcommand[TRequest, TResult]

    // Mocks a command returning a specific error
    MockCommandError[TRequest, TResult](error) *mockcommand[TRequest, TResult]

    // Mocks a command returning an error from an implementation
    // of the Validator interface
    MockCommandValidationError[TRequest, TResult](error) *mockcommand[TRequest, TResult]

There is no factory for mocking a command that returns an error from a ConfigurationChecker interface; such a command would be impossible to register and so could not be called in any test scenario.

The mock returned by these factories provide methods for determining how many times the mock was called, whether it was called at all, as well as copies of all requests received by the mock over its lifetime.

Custom Mocks

If the provided mock factories are not sufficient, you can register a custom mock using the RegisterMockCommand() function. This is similar to the RegisterCommand() function, registering the specified command to handle requests of a specified type and returning a specified result type.

There are two main differences:

  • RegisterMockCommand() does not return any error; if the supplied mock returns an error from any configuration checks, the mock will not be registered and the function will panic.
  • RegisterMockCommand() returns a function to be used to unregister the mock when no longer required (typically immediately deferred to clean up the registration when the test completes)
example
    unreg := RegisterMockCommand[myCommand.Request, NoResultType](ctx, &mockMyCommand{})
    defer unreg()

Documentation

Index

Constants

This section is empty.

Variables

View Source
var NoResult = new(NoResultType)

NoResult may be used in Execute calls to commands that do not return a result:

// calling the command
_, err := mediator.Execute(ctx, MyCommand.Request{}, mediator.NoResult)

Since NoResultType is itself a pointer, when implementing a command that does not return a result simply return a nil result:

// implementing a command returning NoResultType
func (c Command) Execute(ctx context.Context, req Request) (mediator.NoResultType, error) {
  if err := c.doSomething(); err != nil {
    return nil, err
  }
  return nil, nil
}

Functions

func Execute

func Execute[TRequest any, TResult any](ctx context.Context, req TRequest, resultHint *TResult) (TResult, error)

Execute sends the specified request to the registered command for the request type. The result parameter is a type-hint, providing a pointer to a value of the expected result type. The result itself is returned by the function along with any error. The type-hint result pointer is otherwise ignored.

The result type-hint enables the GoLang compiler to infer both the request and result type for the generic function.

The type-hint pointer is not de-referenced so may be nil; alternatively the `new()` function may be used to create a pointer to the result type:

// call the Foo.Request command and capture the result in foo
foo, err := mediator.Execute(ctx, Foo.Request{}, new(Foo.Result))

In the event of an error, the result will be the zero-value of the result type, otherwise it will be the value returned by the command.

A special case is when a command does not return any result.

Such a command would typically be registered with a result type of 'mediator.NoResultType' and called with a type-hint parameter of `mediator.NoResult`. The value returned by the Execute function is discarded in this case:

// call the Bar.Request command which returns only an error
_, err := mediator.Execute(ctx, Bar.Request{}, mediator.NoResult)

If the command implements Validator and the validator returns an error, then the command Execute() function is not called and the error returned will be a ValidationError wrapping the error.

func MockCommand

func MockCommand[TRequest any, TResult any]() *mockcommand[TRequest, TResult]

MockCommand registers a mock command for the specified request and result type modelling successful execution of the command returning a zero-value result and no error.

func MockCommandError

func MockCommandError[TRequest any, TResult any](err error) *mockcommand[TRequest, TResult]

MockCommandError registers a mock command for the specified request and result type modelling a failed execution of the command, returning the specified error and a zero-value result.

func MockCommandResult

func MockCommandResult[TRequest any, TResult any](result TResult) *mockcommand[TRequest, TResult]

MockCommandResult registers a mock command for the specified request and result type modelling successful execution of the command returning the specified result and a nil error.

func MockCommandValidationError

func MockCommandValidationError[TRequest any, TResult any](err error) *mockcommand[TRequest, TResult]

MockCommandValidationError registers a mock command for the specified request and result type modelling a failed validation of the request.

The specified error will be returned by the validator of the mocked command (and will therefore be wrapped in a ValidationError).

func RegisterCommand

func RegisterCommand[TRequest any, TResult any](ctx context.Context, cmd CommandHandler[TRequest, TResult]) error

RegisterCommand[TRequest, TResult] registers a command returning a specific result type for the specified request type.

If a command is already registered for the request type the function will return a CommandAlreadyRegisteredError.

If the command being registered implements the ConfigurationChecker interface, this is called and any error returned.

If the command does not implementation ConfigurationChecker or the configuration check returns no error, then the command is registered.

func RegisterMockCommand

func RegisterMockCommand[TRequest any, TResult any](ctx context.Context, mock CommandHandler[TRequest, TResult]) func()

RegisterMockCommand registers a custom mock command, returning a function to unregister the mock when no longer required:

unreg := mediator.RegisterMockCommand[MyRequest, MyResult](myMock)
defer unreg()

Custom mocks are useful when you want to mock a command that has a custom validator or executor. If a custom mock implements CheckConfiguration, it must not return any error from the configuration check.

If a custom mock fails configuration checks the registration will panic.

Types

type CommandAlreadyRegisteredError

type CommandAlreadyRegisteredError struct {
	// contains filtered or unexported fields
}

NoCommandForRequestTypeError is returned by Execute if there is no command registered for the request and result type involved.

func (CommandAlreadyRegisteredError) Error

func (CommandAlreadyRegisteredError) Is

type CommandHandler

type CommandHandler[TRequest any, TResult any] interface {
	Execute(context.Context, TRequest) (TResult, error)
}

CommandHandler[TRequest, TResult] is the one interface that MUST be implemented by a command.

It provides the Execute function that is called by the mediator.

type ConfigurationChecker

type ConfigurationChecker interface {
	CheckConfiguration(ctx context.Context) error
}

ConfigurationChecker is an optional interface that may be implemented by a command to separate any configuration checks from validation of any specific request or the execution of the command.

If implemented, CheckConfiguration is called when registering a command. if an error is returned, command registration fails and the error is returned from RegisterCommand.

type NoCommandForRequestTypeError

type NoCommandForRequestTypeError struct {
	// contains filtered or unexported fields
}

NoCommandForRequestTypeError is returned by Execute if there is no command registered for the request and result type involved.

func (NoCommandForRequestTypeError) Error

func (NoCommandForRequestTypeError) Is

type NoResultType

type NoResultType *int

NoResultType may be used as the TResult of a command when the command does not return a result. The NoResult value may then be used in Execute calls.

For example:

// to register a command with no result
mediator.RegisterCommand[MyCommand.Request, mediator.NoResultType](MyCommand.Handler{})

// calling the command
_, err := mediator.Execute(ctx, MyCommand.Request{}, mediator.NoResult)

type ResultTypeError

type ResultTypeError struct {
	// contains filtered or unexported fields
}

ResultTypeError is returned if the command registered for the specified request type does not return the result type expected by the caller.

func (ResultTypeError) Error

func (e ResultTypeError) Error() string

func (ResultTypeError) Is

func (e ResultTypeError) Is(target error) bool

type ValidationError

type ValidationError struct {
	E error
}

ValidationError is returned by a command when it is unable to process a request due to the request itself being invalid. The ValidationError wraps a specific error that identifies the problem with the request.

"request validation error: <specific error>"

func (ValidationError) Error

func (e ValidationError) Error() string

func (ValidationError) Unwrap

func (e ValidationError) Unwrap() error

type Validator

type Validator[TRequest any] interface {
	Validate(context.Context, TRequest) error
}

Validator[TRequest] is an optional interface that may be implemented by a command to separate the validation of the request from the execution of the command.

If implemented, the mediator will the Validate function before passing a request to the Execute function; any error returned by Validate is returned (wrapped in a ValidationError).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL