diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml
index 84403c7fd..9f68daf9e 100644
--- a/.github/workflows/unit.yml
+++ b/.github/workflows/unit.yml
@@ -21,7 +21,6 @@ jobs:
- "lowest"
- "highest"
php-version:
- - "8.1"
- "8.2"
- "8.3"
operating-system:
diff --git a/baseline.xml b/baseline.xml
index dac0b4894..c34039bb6 100644
--- a/baseline.xml
+++ b/baseline.xml
@@ -16,6 +16,25 @@
frozenDateTime->modify(sprintf('+%s seconds', $seconds))]]>
+
+
+ parameterResolver->resolve($reflectionMethod, $command)]]>
+
+
+
+
+ parameterResolver->resolve($reflectionMethod, $command)]]>
+
+
+
+
+ method}(...)]]>
+
+
+ method]]>
+ method}(...)]]>
+
+
@@ -57,9 +76,6 @@
-
-
-
aggregateMetadata[$aggregate]]]>
@@ -219,6 +235,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ command]]>
+
+
@@ -229,11 +286,6 @@
-
-
-
-
-
diff --git a/composer.json b/composer.json
index 19a5fb12e..586bf010d 100644
--- a/composer.json
+++ b/composer.json
@@ -19,22 +19,24 @@
}
],
"require": {
- "php": "~8.1.0 || ~8.2.0 || ~8.3.0",
+ "php": "~8.2.0 || ~8.3.0",
"doctrine/dbal": "^4.0.0",
"doctrine/migrations": "^3.3.2",
"patchlevel/hydrator": "^1.5.0",
"patchlevel/worker": "^1.2.0",
"psr/cache": "^2.0.0|^3.0.0",
"psr/clock": "^1.0",
+ "psr/container": "^2.0",
"psr/event-dispatcher": "^1.0",
"psr/log": "^2.0.0|^3.0.0",
"psr/simple-cache": "^2.0.0|^3.0.0",
"ramsey/uuid": "^4.7",
"symfony/console": "^5.4.32|^6.4.1|^7.0.1",
- "symfony/finder": "^5.4.27|^6.4.0|^7.0.0"
+ "symfony/finder": "^5.4.27|^6.4.0|^7.0.0",
+ "symfony/type-info": "^7.2"
},
"require-dev": {
- "ext-pdo_sqlite": "~8.1.0 || ~8.2.0 || ~8.3.0",
+ "ext-pdo_sqlite": "~8.2.0 || ~8.3.0",
"cspray/phinal": "^2.0.0",
"doctrine/orm": "^2.18.0|^3.0.0",
"infection/infection": "^0.27.10",
diff --git a/composer.lock b/composer.lock
index 83992c3b8..ef7f57827 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "c2224a1076fbef35e7ec7873bc9e1aa9",
+ "content-hash": "d7879db342a8541f59507e4410ec5826",
"packages": [
{
"name": "brick/math",
@@ -415,21 +415,22 @@
},
{
"name": "patchlevel/hydrator",
- "version": "1.5.1",
+ "version": "1.6.0",
"source": {
"type": "git",
"url": "https://github.com/patchlevel/hydrator.git",
- "reference": "b2cc966bca642569f0dc6cd74cc77c34de52b7a9"
+ "reference": "484ed357e0647c01b4f6d4d4dc0d9732daa11dac"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/b2cc966bca642569f0dc6cd74cc77c34de52b7a9",
- "reference": "b2cc966bca642569f0dc6cd74cc77c34de52b7a9",
+ "url": "https://api.github.com/repos/patchlevel/hydrator/zipball/484ed357e0647c01b4f6d4d4dc0d9732daa11dac",
+ "reference": "484ed357e0647c01b4f6d4d4dc0d9732daa11dac",
"shasum": ""
},
"require": {
"ext-openssl": "*",
- "php": "~8.1.0 || ~8.2.0 || ~8.3.0"
+ "php": "~8.2.0 || ~8.3.0",
+ "symfony/type-info": "^7.2.2"
},
"require-dev": {
"cspray/phinal": "^2.0.0",
@@ -437,7 +438,7 @@
"patchlevel/coding-standard": "^1.3.0",
"phpbench/phpbench": "^1.2.15",
"phpspec/prophecy-phpunit": "^2.1.0",
- "phpstan/phpstan": "^1.10.49",
+ "phpstan/phpstan": "^2.1.0",
"phpunit/phpunit": "^10.5.2",
"psalm/plugin-phpunit": "^0.19.0",
"psr/cache": "^2.0.0|^3.0.0",
@@ -474,9 +475,9 @@
],
"support": {
"issues": "https://github.com/patchlevel/hydrator/issues",
- "source": "https://github.com/patchlevel/hydrator/tree/1.5.1"
+ "source": "https://github.com/patchlevel/hydrator/tree/1.6.0"
},
- "time": "2024-10-15T11:28:23+00:00"
+ "time": "2025-01-02T11:56:20+00:00"
},
{
"name": "patchlevel/worker",
@@ -1953,6 +1954,81 @@
],
"time": "2024-11-13T13:31:26+00:00"
},
+ {
+ "name": "symfony/type-info",
+ "version": "v7.2.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/type-info.git",
+ "reference": "3b5a17470fff0034f25fd4287cbdaa0010d2f749"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/type-info/zipball/3b5a17470fff0034f25fd4287cbdaa0010d2f749",
+ "reference": "3b5a17470fff0034f25fd4287cbdaa0010d2f749",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.2",
+ "psr/container": "^1.1|^2.0"
+ },
+ "require-dev": {
+ "phpstan/phpdoc-parser": "^1.0|^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\TypeInfo\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mathias Arlaud",
+ "email": "mathias.arlaud@gmail.com"
+ },
+ {
+ "name": "Baptiste LEDUC",
+ "email": "baptiste.leduc@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Extracts PHP types information.",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "PHPStan",
+ "phpdoc",
+ "symfony",
+ "type"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/type-info/tree/v7.2.2"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-12-20T13:38:37+00:00"
+ },
{
"name": "symfony/var-exporter",
"version": "v7.2.0",
@@ -8282,14 +8358,14 @@
],
"aliases": [],
"minimum-stability": "stable",
- "stability-flags": {},
+ "stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": {
- "php": "~8.1.0 || ~8.2.0 || ~8.3.0"
+ "php": "~8.2.0 || ~8.3.0"
},
"platform-dev": {
- "ext-pdo_sqlite": "~8.1.0 || ~8.2.0 || ~8.3.0"
+ "ext-pdo_sqlite": "~8.2.0 || ~8.3.0"
},
"plugin-api-version": "2.6.0"
}
diff --git a/deptrac.yaml b/deptrac.yaml
index b793814f6..941f4891d 100644
--- a/deptrac.yaml
+++ b/deptrac.yaml
@@ -17,6 +17,10 @@ deptrac:
collectors:
- type: directory
value: src/Clock/.*
+ - name: CommandBus
+ collectors:
+ - type: directory
+ value: src/CommandBus/.*
- name: Console
collectors:
- type: directory
@@ -103,6 +107,11 @@ deptrac:
- MetadataAggregate
Attribute:
Clock:
+ CommandBus:
+ - Aggregate
+ - Attribute
+ - MetadataAggregate
+ - Repository
Console:
- Aggregate
- Message
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml
index 2ee820843..f3e85c770 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -97,6 +97,7 @@ nav:
- Message: message.md
- Store: store.md
- Subscription: subscription.md
+ - Command Bus: command_bus.md
- Event Bus: event_bus.md
- Advanced:
- Aggregate ID: aggregate_id.md
diff --git a/docs/pages/command_bus.md b/docs/pages/command_bus.md
new file mode 100644
index 000000000..1c0ba692a
--- /dev/null
+++ b/docs/pages/command_bus.md
@@ -0,0 +1,323 @@
+# Command Bus
+
+The Command Bus is an optional component in the Event Sourcing library that coordinates the execution of commands.
+It allows commands to be forwarded to the appropriate aggregates and their handlers to be invoked.
+This promotes a clear separation of responsibilities and simplifies the management of business logic.
+
+## Command
+
+First of all, you need to create a command class.
+A command is a simple data transfer object that represents an intention to perform an action.
+
+```php
+final class CreateProfile
+{
+ public function __construct(
+ public readonly ProfileId $id,
+ public readonly string $name,
+ ) {
+ }
+}
+```
+## Handler
+
+Then you need to create a handler class.
+A handler is a class that contains the business logic for a command.
+It will be invoked when a command is dispatched.
+You need to mark the method that handles the command with the `#[Handle]` attribute.
+
+```php
+use Patchlevel\EventSourcing\Attribute\Handle;
+
+final class CreateProfileHandler
+{
+ #[Handle]
+ public function __invoke(CreateProfile $command): void
+ {
+ // handle command
+ }
+}
+```
+!!! note
+
+ To use Service Handler you need to register the handler in the `ServiceHandlerProvider`.
+
+!!! tip
+
+ A class can have multiple handle methods.
+
+### Aggregate Handler
+
+Another way to handle commands is to use the aggregates themselves.
+To do this, you need to mark the method that handles the command with the `#[Handle]` attribute.
+
+!!! note
+
+ The aggregates themselves are of course not a service.
+ The AggregateHandlerProvider uses the aggregates to create the handlers for you.
+ You can find out more about this in the [providers](./command_bus.md#provider) section.
+
+#### Create Aggregate
+
+If you want to create a new aggregate, you need to create a static method that returns a new instance of the aggregate.
+
+```php
+use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
+use Patchlevel\EventSourcing\Attribute\Aggregate;
+use Patchlevel\EventSourcing\Attribute\Handle;
+use Patchlevel\EventSourcing\Attribute\Id;
+
+#[Aggregate('profile')]
+final class Profile extends BasicAggregateRoot
+{
+ #[Id]
+ private ProfileId $id;
+ private string $name;
+
+ #[Handle]
+ public static function create(CreateProfile $command): self
+ {
+ $self = new self();
+ $self->recordThat(new ProfileCreated($command->id, $command->name));
+
+ return $self;
+ }
+
+ // ... apply methods
+}
+```
+!!! tip
+
+ You can find more information about aggregates [here](aggregate.md).
+
+#### Update Aggregate
+
+If you want to update an existing aggregate,
+first you need to mark the `aggregate id` with the `#[Id]` attribute in the command class.
+Otherwise, the handler does not know which aggregates should be loaded.
+
+```php
+use Patchlevel\EventSourcing\Attribute\Id;
+
+final class ChangeProfileName
+{
+ public function __construct(
+ #[Id]
+ public readonly ProfileId $id,
+ public readonly string $name,
+ ) {
+ }
+}
+```
+Then you need to create a method that changes the aggregate state.
+Here too, you need to mark the method with the `#[Handle]` attribute.
+
+```php
+use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
+use Patchlevel\EventSourcing\Attribute\Aggregate;
+use Patchlevel\EventSourcing\Attribute\Handle;
+use Patchlevel\EventSourcing\Attribute\Id;
+
+#[Aggregate('profile')]
+final class Profile extends BasicAggregateRoot
+{
+ #[Id]
+ private ProfileId $id;
+ private string $name;
+
+ #[Handle]
+ public function changeName(ChangeProfileName $command): void
+ {
+ if (!$nameValidator($command->name)) {
+ throw new InvalidArgument();
+ }
+
+ $this->recordThat(new NameChanged($command->name));
+ }
+
+ // ... apply methods
+}
+```
+#### Inject Service
+
+You can inject services into aggregate handler methods.
+Starting with the second parameter, it automatically tries to inject the service using a service locator.
+Standard, it uses the fully qualified class name from the parameter type hint to find the service.
+
+```php
+use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
+use Patchlevel\EventSourcing\Attribute\Aggregate;
+use Patchlevel\EventSourcing\Attribute\Handle;
+use Psr\Clock\ClockInterface;
+
+#[Aggregate('profile')]
+final class Profile extends BasicAggregateRoot
+{
+ #[Handle]
+ public static function create(
+ CreateProfile $command,
+ ClockInterface $clock,
+ ): self {
+ $self = new self();
+
+ $self->recordThat(new ProfileCreated(
+ $command->id,
+ $command->name,
+ $clock->now(),
+ ));
+
+ return $self;
+ }
+
+ // ... apply methods
+}
+```
+!!! note
+
+ The service must be registered in the service locator.
+
+!!! tip
+
+ You can inject multiple services into the handler method.
+
+Or you can inject the service manually using the `#[Inject]` attribute.
+There you can specify the service name that should be injected.
+
+```php
+use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot;
+use Patchlevel\EventSourcing\Attribute\Aggregate;
+use Patchlevel\EventSourcing\Attribute\Handle;
+use Patchlevel\EventSourcing\Attribute\Inject;
+
+#[Aggregate('profile')]
+final class Profile extends BasicAggregateRoot
+{
+ #[Handle]
+ public static function create(
+ CreateProfile $command,
+ #[Inject('name_validator')]
+ NameValidator $nameValidator,
+ ): self {
+ $self = new self();
+
+ if (!$nameValidator($command->name)) {
+ throw new InvalidArgument();
+ }
+
+ $self->recordThat(new ProfileCreated($command->id, $command->name));
+
+ return $self;
+ }
+
+ // ... apply methods
+}
+```
+!!! note
+
+ Injection in handler methods is only possible with the `AggregateHandlerProvider`.
+
+## Setup
+
+We provide a `SyncCommandBus` that you can use to dispatch commands.
+You need to pass a `HandlerProvider` to the constructor.
+
+```php
+use Patchlevel\EventSourcing\CommandBus\HandlerProvider;
+use Patchlevel\EventSourcing\CommandBus\SyncCommandBus;
+
+/** @var HandlerProvider $handlerProvider */
+$commandBus = new SyncCommandBus($handlerProvider);
+
+$commandBus->dispatch(new CreateProfile($profileId, 'name'));
+$commandBus->dispatch(new ChangeProfileName($profileId, 'new name'));
+```
+!!! note
+
+ The `SyncCommandBus` is a synchronous command bus.
+ But it ensures that a command has been completely handled before the next handler is executed.
+
+## Provider
+
+There are different types of providers that you can use to register handlers.
+
+### Service Handler Provider
+
+The classically way to handle commands is to use services.
+The `ServiceHandlerProvider` is used to handle commands by invoking methods on services.
+
+```php
+use Patchlevel\EventSourcing\CommandBus\ServiceHandlerProvider;
+
+$provider = new ServiceHandlerProvider([
+ new CreateProfileHandler(),
+ new ChangeProfileNameHandler(
+ new NameValidator(),
+ ),
+]);
+```
+### Aggregate Handler Provider
+
+The `AggregateHandlerProvider` is used to handle commands by invoking methods on aggregates.
+The special thing about it is that the aggregates themselves are not services,
+but the handler provider automatically creates suitable handler services for the aggregates.
+
+```php
+use Patchlevel\EventSourcing\CommandBus\AggregateHandlerProvider;
+use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry;
+use Patchlevel\EventSourcing\Repository\RepositoryManager;
+
+/**
+ * @var AggregateRootRegistry $aggregateRootRegistry
+ * @var RepositoryManager $repositoryManager
+ */
+$provider = new AggregateHandlerProvider(
+ $aggregateRootRegistry,
+ $repositoryManager,
+);
+```
+#### Service Locator
+
+If you want service injection in aggregate handler methods,
+you need to pass a service locator to the `AggregateHandlerProvider`.
+You can use any psr-11 compatible container, or you can use our implementation `ServiceLocator`.
+
+```php
+use Patchlevel\EventSourcing\CommandBus\AggregateHandlerProvider;
+use Patchlevel\EventSourcing\CommandBus\ServiceLocator;
+use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry;
+use Patchlevel\EventSourcing\Repository\RepositoryManager;
+
+/**
+ * @var AggregateRootRegistry $aggregateRootRegistry
+ * @var RepositoryManager $repositoryManager
+ */
+$provider = new AggregateHandlerProvider(
+ $aggregateRootRegistry,
+ $repositoryManager,
+ new ServiceLocator([
+ 'name_validator' => new NameValidator(),
+ ]), // or other psr-11 compatible container
+);
+```
+!!! tip
+
+ You can find suitable implementations of psr-11 containers on [packagist](https://packagist.org/search/?tags=PSR-11).
+
+### Chain Handler Provider
+
+The `ChainHandlerProvider` allows you to combine multiple handler providers.
+
+```php
+use Patchlevel\EventSourcing\CommandBus\ChainHandlerProvider;
+
+$provider = new ChainHandlerProvider([
+ $serviceHandlerProvider,
+ $aggregateHandlerProvider,
+]);
+```
+## Learn more
+
+* [How to use aggregates](aggregate.md)
+* [How to use events](events.md)
+* [How to use clock](clock.md)
+* [How to use aggregate id](aggregate_id.md)
diff --git a/src/Attribute/Handle.php b/src/Attribute/Handle.php
new file mode 100644
index 000000000..65a5bdfa3
--- /dev/null
+++ b/src/Attribute/Handle.php
@@ -0,0 +1,17 @@
+> */
+ private array $handlers = [];
+
+ public function __construct(
+ private readonly AggregateRootRegistry $aggregateRootRegistry,
+ private readonly RepositoryManager $repositoryManager,
+ private readonly ContainerInterface|null $container = null,
+ ) {
+ }
+
+ /**
+ * @param class-string $commandClass
+ *
+ * @return iterable
+ */
+ public function handlerForCommand(string $commandClass): iterable
+ {
+ if (!$this->initialized) {
+ $this->initialize();
+ }
+
+ return $this->handlers[$commandClass] ?? [];
+ }
+
+ private function initialize(): void
+ {
+ foreach ($this->aggregateRootRegistry->aggregateClasses() as $aggregateClass) {
+ foreach (HandlerFinder::findInClass($aggregateClass) as $handler) {
+ if ($handler->static) {
+ $this->handlers[$handler->commandClass][] = new HandlerDescriptor(
+ new CreateAggregateHandler(
+ $this->repositoryManager,
+ $aggregateClass,
+ $handler->method,
+ new DefaultParameterResolver($this->container),
+ ),
+ );
+
+ continue;
+ }
+
+ $this->handlers[$handler->commandClass][] = new HandlerDescriptor(
+ new UpdateAggregateHandler(
+ $this->repositoryManager,
+ $aggregateClass,
+ $handler->method,
+ new DefaultParameterResolver($this->container),
+ ),
+ );
+ }
+ }
+
+ $this->initialized = true;
+ }
+}
diff --git a/src/CommandBus/ChainHandlerProvider.php b/src/CommandBus/ChainHandlerProvider.php
new file mode 100644
index 000000000..8ea809b33
--- /dev/null
+++ b/src/CommandBus/ChainHandlerProvider.php
@@ -0,0 +1,33 @@
+ $providers */
+ public function __construct(
+ private readonly iterable $providers,
+ ) {
+ }
+
+ /**
+ * @param class-string $commandClass
+ *
+ * @return iterable
+ */
+ public function handlerForCommand(string $commandClass): iterable
+ {
+ $handlers = [];
+
+ foreach ($this->providers as $provider) {
+ $handlers = [
+ ...$handlers,
+ ...$provider->handlerForCommand($commandClass),
+ ];
+ }
+
+ return $handlers;
+ }
+}
diff --git a/src/CommandBus/CommandBus.php b/src/CommandBus/CommandBus.php
new file mode 100644
index 000000000..f47ee9c28
--- /dev/null
+++ b/src/CommandBus/CommandBus.php
@@ -0,0 +1,11 @@
+ $aggregateClass */
+ public function __construct(
+ private readonly RepositoryManager $repositoryManager,
+ private readonly string $aggregateClass,
+ private readonly string $methodName,
+ private readonly ParameterResolver $parameterResolver,
+ ) {
+ }
+
+ public function __invoke(object $command): void
+ {
+ $repository = $this->repositoryManager->get($this->aggregateClass);
+
+ $reflection = new ReflectionClass($this->aggregateClass);
+ $reflectionMethod = $reflection->getMethod($this->methodName);
+
+ $aggregate = $reflectionMethod->invokeArgs(
+ null,
+ [...$this->parameterResolver->resolve($reflectionMethod, $command)],
+ );
+
+ if (!$aggregate instanceof AggregateRoot) {
+ throw new InvalidArgumentException('create method must return an instance of AggregateRoot');
+ }
+
+ $repository->save($aggregate);
+ }
+}
diff --git a/src/CommandBus/Handler/DefaultParameterResolver.php b/src/CommandBus/Handler/DefaultParameterResolver.php
new file mode 100644
index 000000000..d9e8bd8bc
--- /dev/null
+++ b/src/CommandBus/Handler/DefaultParameterResolver.php
@@ -0,0 +1,71 @@
+ */
+ public function resolve(ReflectionMethod $method, object $command): iterable
+ {
+ foreach ($method->getParameters() as $index => $parameter) {
+ if ($index === 0) {
+ yield $command; // first parameter is always the command
+
+ continue;
+ }
+
+ if (!$this->container) {
+ throw ServiceNotResolvable::missingContainer();
+ }
+
+ try {
+ yield $this->container->get(self::serviceName($method, $parameter));
+ } catch (ContainerExceptionInterface $exception) {
+ throw ServiceNotResolvable::missingService(
+ $method->getDeclaringClass()->getName(),
+ $method->getName(),
+ $parameter->getName(),
+ $exception,
+ );
+ }
+ }
+ }
+
+ private function serviceName(ReflectionMethod $method, ReflectionParameter $parameter): string
+ {
+ $attributes = $parameter->getAttributes(Inject::class);
+
+ if ($attributes !== []) {
+ return $attributes[0]->newInstance()->service;
+ }
+
+ $reflectionType = $parameter->getType();
+
+ if ($reflectionType === null) {
+ throw ServiceNotResolvable::missingType($method->getDeclaringClass()->getName(), $parameter->getName());
+ }
+
+ $type = TypeResolver::create()->resolve($reflectionType);
+
+ if (!$type instanceof ObjectType) {
+ throw ServiceNotResolvable::typeNotObject($method->getDeclaringClass()->getName(), $parameter->getName());
+ }
+
+ return $type->getClassName();
+ }
+}
diff --git a/src/CommandBus/Handler/ParameterResolver.php b/src/CommandBus/Handler/ParameterResolver.php
new file mode 100644
index 000000000..53145a801
--- /dev/null
+++ b/src/CommandBus/Handler/ParameterResolver.php
@@ -0,0 +1,13 @@
+ */
+ public function resolve(ReflectionMethod $method, object $command): iterable;
+}
diff --git a/src/CommandBus/Handler/ServiceNotResolvable.php b/src/CommandBus/Handler/ServiceNotResolvable.php
new file mode 100644
index 000000000..79913d386
--- /dev/null
+++ b/src/CommandBus/Handler/ServiceNotResolvable.php
@@ -0,0 +1,45 @@
+getMessage(),
+ ),
+ 0,
+ $exception,
+ );
+ }
+}
diff --git a/src/CommandBus/Handler/UpdateAggregateHandler.php b/src/CommandBus/Handler/UpdateAggregateHandler.php
new file mode 100644
index 000000000..b3adfb8c5
--- /dev/null
+++ b/src/CommandBus/Handler/UpdateAggregateHandler.php
@@ -0,0 +1,65 @@
+ $aggregateClass */
+ public function __construct(
+ private readonly RepositoryManager $repositoryManager,
+ private readonly string $aggregateClass,
+ private readonly string $methodName,
+ private readonly ParameterResolver $parameterResolver,
+ ) {
+ }
+
+ public function __invoke(object $command): void
+ {
+ $aggregateRootId = $this->aggregateRootId($command);
+ $repository = $this->repositoryManager->get($this->aggregateClass);
+
+ $aggregate = $repository->load($aggregateRootId);
+
+ $reflection = new ReflectionClass($this->aggregateClass);
+ $reflectionMethod = $reflection->getMethod($this->methodName);
+
+ $reflectionMethod->invokeArgs(
+ $aggregate,
+ [...$this->parameterResolver->resolve($reflectionMethod, $command)],
+ );
+
+ $repository->save($aggregate);
+ }
+
+ private function aggregateRootId(object $command): AggregateRootId
+ {
+ $reflectionClass = new ReflectionClass($command);
+
+ foreach ($reflectionClass->getProperties() as $property) {
+ $attributes = $property->getAttributes(Id::class);
+
+ if ($attributes === []) {
+ continue;
+ }
+
+ $value = $property->getValue($command);
+
+ if (!$value instanceof AggregateRootId) {
+ throw new InvalidArgumentException('Id property must be an instance of AggregateRootId');
+ }
+
+ return $value;
+ }
+
+ throw new AggregateIdNotFound($command::class);
+ }
+}
diff --git a/src/CommandBus/HandlerDescriptor.php b/src/CommandBus/HandlerDescriptor.php
new file mode 100644
index 000000000..99a68a3e9
--- /dev/null
+++ b/src/CommandBus/HandlerDescriptor.php
@@ -0,0 +1,49 @@
+callable = $callable(...);
+ $this->name = self::closureName($this->callable);
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+
+ public function callable(): callable
+ {
+ return $this->callable;
+ }
+
+ private static function closureName(Closure $closure): string
+ {
+ $reflectionFunction = new ReflectionFunction($closure);
+
+ if ($reflectionFunction->isAnonymous()) {
+ return 'Closure';
+ }
+
+ $closureThis = $reflectionFunction->getClosureThis();
+
+ if (!$closureThis) {
+ $class = $reflectionFunction->getClosureCalledClass();
+
+ return ($class ? $class->name . '::' : '') . $reflectionFunction->name;
+ }
+
+ return $closureThis::class . '::' . $reflectionFunction->name;
+ }
+}
diff --git a/src/CommandBus/HandlerFinder.php b/src/CommandBus/HandlerFinder.php
new file mode 100644
index 000000000..f00af8609
--- /dev/null
+++ b/src/CommandBus/HandlerFinder.php
@@ -0,0 +1,80 @@
+
+ */
+ public static function findInClass(string $classString): iterable
+ {
+ $typeResolver = TypeResolver::create();
+ $reflectionClass = new ReflectionClass($classString);
+
+ foreach ($reflectionClass->getMethods() as $reflectionMethod) {
+ $handleAttributes = $reflectionMethod->getAttributes(Handle::class);
+
+ if ($handleAttributes === []) {
+ continue;
+ }
+
+ $handle = $handleAttributes[0]->newInstance();
+
+ if ($handle->commandClass !== null) {
+ yield new HandlerReference(
+ $handle->commandClass,
+ $reflectionMethod->getName(),
+ $reflectionMethod->isStatic(),
+ );
+
+ continue;
+ }
+
+ $parameters = $reflectionMethod->getParameters();
+
+ if ($parameters === []) {
+ throw InvalidHandleMethod::noParameters(
+ $reflectionMethod->getDeclaringClass()->getName(),
+ $reflectionMethod->getName(),
+ );
+ }
+
+ $reflectionType = $parameters[0]->getType();
+
+ if ($reflectionType === null) {
+ throw InvalidHandleMethod::incompatibleType(
+ $reflectionMethod->getDeclaringClass()->getName(),
+ $reflectionMethod->getName(),
+ );
+ }
+
+ $type = $typeResolver->resolve($reflectionType);
+
+ if (!$type instanceof ObjectType) {
+ throw InvalidHandleMethod::incompatibleType(
+ $reflectionMethod->getDeclaringClass()->getName(),
+ $reflectionMethod->getName(),
+ );
+ }
+
+ /** @var class-string $commandClass */
+ $commandClass = $type->getClassName();
+
+ yield new HandlerReference(
+ $commandClass,
+ $reflectionMethod->getName(),
+ $reflectionMethod->isStatic(),
+ );
+ }
+ }
+}
diff --git a/src/CommandBus/HandlerNotFound.php b/src/CommandBus/HandlerNotFound.php
new file mode 100644
index 000000000..476f4a17b
--- /dev/null
+++ b/src/CommandBus/HandlerNotFound.php
@@ -0,0 +1,18 @@
+
+ */
+ public function handlerForCommand(string $commandClass): iterable;
+}
diff --git a/src/CommandBus/HandlerReference.php b/src/CommandBus/HandlerReference.php
new file mode 100644
index 000000000..3cb97550f
--- /dev/null
+++ b/src/CommandBus/HandlerReference.php
@@ -0,0 +1,16 @@
+> */
+ private array $handlers = [];
+
+ /** @param iterable