nepada/message-bus

Opinionated message bus built on top of symfony/messenger.

v3.0.1 2024-10-27 16:10 UTC

README

Build Status Coverage Status Downloads this Month Latest stable

Opinionated message bus built on top of symfony/messenger largely based on the ideas and code base of damejidlo/message-bus, originally developed by Ondřej Bouda.

Installation

Via Composer:

$ composer require nepada/message-bus

Conventions

We define two types of messages and corresponding message buses - commands and events.

Commands

Command implementation must adhere to these rules:

  • class must implement Nepada\Commands\Command interface
  • class must be named <command-name>Command
  • class must be final
  • class must be readonly
  • command name should be in imperative form ("do something")
  • command must be a simple immutable DTO
  • command must not contain entities, only references (i.e. int $orderId, not Order $order)

Examples of good command class names:

  • RejectOrderCommand
  • CreateUserCommand

Command handler implementation must adhere to these rules:

  • class must implement Nepada\Commands\CommandHandler interface
  • class must be named <command-name>Handler
  • class must be final
  • class must implement method named __invoke
  • __invoke method must have exactly one parameter named $command
  • __invoke method parameter must be typehinted with specific command class
  • __invoke method return type must be void
  • __invoke method must be annotated with @throws tags if specific exceptions can be thrown

Example:

final class DoSomethingHandler implements \Nepada\MessageBus\Commands\CommandHandler
{
    /**
     * @throws SomeException
     */
    public function __invoke(DoSomethingCommand $command): void
    {
        // ...
    }
}

Every command must have exactly one handler.

Events

Events must be dispatched during command handling only.

Event implementation must adhere to these rules:

  • class must implement Nepada\Events\Event interface
  • class must be named <event-name>Event
  • class must be final
  • class must be readonly
  • event name should be in past tense ("something happened")
  • event must be a simple immutable DTO
  • event must not contain entities, only references (i.e. int $orderId, not Order $order)

Examples of good event class names:

  • OrderRejectedEvent
  • UserRegisteredEvent

Event subscriber implementation must adhere to these rules:

  • class must implement Nepada\Events\EventSubscriber interface
  • class must be named <do-something>On<event-name>
  • class must be final
  • class must implement method named __invoke
  • __invoke method must have exactly one parameter named $event
  • __invoke method parameter must be typehinted with specific event class
  • __invoke method return type must be void
  • __invoke method must be annotated with @throws tags if specific exceptions can be thrown

Example:

final class DoSomethingOnSomethingHappened implements \Nepada\MessageBus\Events\EventSubscriber
{
     public function __invoke(SomethingHappenedEvent $event): void {}
}

Every event may have any number of subscribers, or none at all.

Configuration & Usage

Enforcing conventions by static analysis

Most of the conventions described above may be enforced by static analysis. The analysis should be run during the compilation of DI container, triggering it at application runtime is not recommended.

use Nepada\MessageBus\StaticAnalysis\ConfigurableHandlerValidator;
use Nepada\MessageBus\StaticAnalysis\HandlerType;
use Nepada\MessageBus\StaticAnalysis\MessageHandlerValidationConfiguration;

// Validate command handler
$commandHandlerType = HandlerType::fromString(DoSomethingHandler::class);
$commandHandlerConfiguration = MessageHandlerValidationConfiguration::command();
$commandHandlerValidator = new ConfigurableHandlerValidator($commandHandlerConfiguration);
$commandHandlerValidator->validate($commandHandlerType);

// Validate event subscriber
$eventSubscriberType = HandlerType::fromString(DoSomethingOnSomethingHappened::class);
$eventSubscriberConfiguration = MessageHandlerValidationConfiguration::event();
$eventSubscriberValidator = new ConfigurableHandlerValidator($eventSubscriberConfiguration);
$eventSubscriberValidator->validate($eventSubscriberType);

Bleeding edge (new rules)

To maintain backwards compatibility new rules are not enforced by default. They can be enabled by passing $bleedingEdge flag when creating validator configuration, e.g. MessageHandlerValidationConfiguration::command(true).

These rules will be enabled in the next major version: (none at the moment)

Extracting handled message type from handler class

Use MessageTypeExtractor to retrieve the message type that a given command handler or event subscriber handles:

use Nepada\MessageBus\StaticAnalysis\HandlerType;
use Nepada\MessageBus\StaticAnalysis\MessageTypeExtractor;

// Extracting handled message type
$messageTypeExtractor = new MessageTypeExtractor();
$commandHandlerType = HandlerType::fromString(DoSomethingHandler::class);
$messageTypeExtractor->extract($commandHandlerType); // MessageType instance for DoSomethingCommand

Logging

LoggingMiddleware implements logging into standard PSR-3 logger. Start of message handling and its success or failure are logged separately. Logging context is filled with the extracted attributes of command or event DTO.

Nested message handling

Generally, it's not a good idea to execute commands from within another command handler. You can completely forbid this behavior with PreventNestedHandlingMiddleware.

Configuration

It is completely up to you to use the provided building blocks together with Symfony Messenger and configure one or more instances of command and/or event buses.

A minimal setup in pure PHP might look something like this:

use Nepada\MessageBus\Commands\CommandHandlerLocator;
use Nepada\MessageBus\Commands\MessengerCommandBus;
use Nepada\MessageBus\Events\EventSubscribersLocator;
use Nepada\MessageBus\Events\MessengerEventDispatcher;
use Nepada\MessageBus\Logging\LogMessageResolver;
use Nepada\MessageBus\Logging\MessageContextResolver;
use Nepada\MessageBus\Logging\PrivateClassPropertiesExtractor;
use Nepada\MessageBus\Middleware\LoggingMiddleware;
use Nepada\MessageBus\Middleware\PreventNestedHandlingMiddleware;
use Symfony\Component\Messenger\MessageBus;
use Symfony\Component\Messenger\Middleware\DispatchAfterCurrentBusMiddleware;
use Symfony\Component\Messenger\Middleware\HandleMessageMiddleware;

$dispatchAfterCurrentBusMiddleware = new DispatchAfterCurrentBusMiddleware();
$preventNestedHandlingMiddleware = new PreventNestedHandlingMiddleware();
$loggingMiddleware = new LoggingMiddleware(
    new LogMessageResolver(),
    new MessageContextResolver(
        new PrivateClassPropertiesExtractor(),
    ),
    $psrLogger,
);
$handleCommandMiddleware = new HandleMessageMiddleware(
    new CommandHandlerLocator(
        $psrContainer,
        [
            DoSomethingCommand::class => 'doSomethingHandlerServiceName',
        ],
    ),
);
$handleEventMiddleware = new HandleMessageMiddleware(
    new EventSubscribersLocator(
        $psrContainer,
        [
            SomethingHappenedEvent::class => [
                'doSomethingOnSomethingHappenedServiceName',
                'doSomethingElseOnSomethingHappenedServiceName',
            ],
        ],
    ),
);
$eventDispatcher = new MessengerEventDispatcher(
    new MessageBus([
        $dispatchAfterCurrentBusMiddleware,
        $loggingMiddleware,
        $handleEventMiddleware,
    ]),
);
$commandBus = new MessengerCommandBus(
    new MessageBus([
        $dispatchAfterCurrentBusMiddleware,
        $loggingMiddleware,
        $preventNestedHandlingMiddleware,
        $handleCommandMiddleware,
    ]),
);

Note the usage of DispatchAfterCurrentBusMiddleware - this is necessary to ensure that events produced during the handling of a command are handled only after the command handling successfully finishes.

For Nette Framework integration, consider using nepada/message-bus-nette.

Extensions

Credits

Static analysis part of the code base and a lot of other core ideas are borrowed from damejidlo/message-bus, originally developed by Ondřej Bouda.