Skip to content

Commit d083916

Browse files
committed
Merge branch 'develop' into next
2 parents 57d1b75 + c882762 commit d083916

11 files changed

Lines changed: 305 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ All notable changes to this project will be documented in this file. This projec
1212

1313
## Unreleased
1414

15+
## [5.2.0] - 2026-01-05
16+
17+
### Added
18+
19+
- New domain event dispatcher features:
20+
- Can now use a PSR container for the domain event dispatcher to resolve both handlers and middleware. Inject the
21+
service container via the first constructor argument. This works for all three domain event dispatchers.
22+
- Domain events can now be mapped to handlers on a domain event dispatcher class via the `ListonTo` attribute.
23+
- Middleware can now be added to a domain event dispatcher via the `Through` attribute.
24+
- The unit of work domain event dispatcher will now log debug messages when deferring and executing listeners. To enable
25+
this, inject an optional logger instance via the constructor.
26+
1527
## [5.1.0] - 2026-01-01
1628

1729
### Added
@@ -612,6 +624,10 @@ All notable changes to this project will be documented in this file. This projec
612624

613625
Initial release.
614626

627+
[5.2.0]: https://github.com/cloudcreativity/ddd-modules/compare/v5.1.0...v5.2.0
628+
629+
[5.1.0]: https://github.com/cloudcreativity/ddd-modules/compare/v5.0.0...v5.1.0
630+
615631
[5.0.0]: https://github.com/cloudcreativity/ddd-modules/compare/v5.0.0-rc.4...v5.0.0
616632

617633
[5.0.0-rc.4]: https://github.com/cloudcreativity/ddd-modules/compare/v5.0.0-rc.3...v5.0.0-rc.4

src/Application/DomainEventDispatching/Dispatcher.php

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,22 @@
1616
use CloudCreativity\Modules\Contracts\Application\DomainEventDispatching\ListenerContainer as IListenerContainer;
1717
use CloudCreativity\Modules\Contracts\Domain\Events\DomainEvent;
1818
use CloudCreativity\Modules\Contracts\Domain\Events\DomainEventDispatcher;
19-
use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer;
19+
use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer as IPipeContainer;
2020
use CloudCreativity\Modules\Toolkit\Pipeline\MiddlewareProcessor;
21+
use CloudCreativity\Modules\Toolkit\Pipeline\PipeContainer;
2122
use CloudCreativity\Modules\Toolkit\Pipeline\PipelineBuilder;
23+
use CloudCreativity\Modules\Toolkit\Pipeline\Through;
2224
use Generator;
2325
use InvalidArgumentException;
26+
use Psr\Container\ContainerInterface;
27+
use ReflectionClass;
2428

2529
class Dispatcher implements DomainEventDispatcher
2630
{
31+
private readonly IListenerContainer $listeners;
32+
33+
private readonly ?IPipeContainer $middleware;
34+
2735
/**
2836
* @var array<string, array<callable|string>>
2937
*/
@@ -35,9 +43,18 @@ class Dispatcher implements DomainEventDispatcher
3543
private array $pipes = [];
3644

3745
public function __construct(
38-
private readonly IListenerContainer $listeners = new ListenerContainer(),
39-
private readonly ?PipeContainer $middleware = null,
46+
ContainerInterface|IListenerContainer $listeners = new ListenerContainer(),
47+
?IPipeContainer $middleware = null,
4048
) {
49+
$this->listeners = $listeners instanceof ContainerInterface ?
50+
new ListenerContainer($listeners) :
51+
$listeners;
52+
53+
$this->middleware = $middleware === null && $listeners instanceof ContainerInterface ?
54+
new PipeContainer($listeners) :
55+
$middleware;
56+
57+
$this->autowire();
4158
}
4259

4360
/**
@@ -135,4 +152,18 @@ private function canAttach(mixed $listener): bool
135152

136153
return is_string($listener) && !empty($listener);
137154
}
155+
156+
private function autowire(): void
157+
{
158+
$reflection = new ReflectionClass($this);
159+
160+
foreach ($reflection->getAttributes(ListenTo::class) as $attribute) {
161+
$instance = $attribute->newInstance();
162+
$this->listen($instance->event, $instance->listeners);
163+
}
164+
165+
foreach ($reflection->getAttributes(Through::class) as $attribute) {
166+
$this->pipes = $attribute->newInstance()->pipes;
167+
}
168+
}
138169
}

src/Application/DomainEventDispatching/EventHandler.php

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,26 @@
1616
use CloudCreativity\Modules\Contracts\Application\UnitOfWork\DispatchAfterCommit;
1717
use CloudCreativity\Modules\Contracts\Application\UnitOfWork\DispatchBeforeCommit;
1818
use CloudCreativity\Modules\Contracts\Domain\Events\DomainEvent;
19+
use Stringable;
1920

20-
final readonly class EventHandler
21+
final readonly class EventHandler implements Stringable
2122
{
2223
public function __construct(private object $listener)
2324
{
2425
assert(
2526
!($this->listener instanceof DispatchBeforeCommit && $this->listener instanceof DispatchAfterCommit),
2627
sprintf(
27-
'Listener "%s" cannot be dispatched both before and after a unit of work is committed..',
28+
'Listener "%s" cannot be dispatched both before and after a unit of work is committed.',
2829
get_debug_type($this->listener),
2930
),
3031
);
3132
}
3233

34+
public function __toString(): string
35+
{
36+
return $this->listener::class;
37+
}
38+
3339
/**
3440
* Should the handler be executed before the transaction is committed?
3541
*/
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* Copyright 2026 Cloud Creativity Limited
5+
*
6+
* Use of this source code is governed by an MIT-style
7+
* license that can be found in the LICENSE file or at
8+
* https://opensource.org/licenses/MIT.
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace CloudCreativity\Modules\Application\DomainEventDispatching;
14+
15+
use Attribute;
16+
use CloudCreativity\Modules\Contracts\Domain\Events\DomainEvent;
17+
18+
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
19+
final readonly class ListenTo
20+
{
21+
/**
22+
* @param class-string<DomainEvent> $event
23+
* @param list<non-empty-string>|non-empty-string $listeners
24+
*/
25+
public function __construct(public string $event, public array|string $listeners)
26+
{
27+
}
28+
}

src/Application/DomainEventDispatching/ListenerContainer.php

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,35 +15,51 @@
1515
use Closure;
1616
use CloudCreativity\Modules\Application\ApplicationException;
1717
use CloudCreativity\Modules\Contracts\Application\DomainEventDispatching\ListenerContainer as IListenerContainer;
18+
use Psr\Container\ContainerInterface;
1819

1920
final class ListenerContainer implements IListenerContainer
2021
{
2122
/**
22-
* @var array<non-empty-string,Closure>
23+
* @var array<non-empty-string, Closure|string>
2324
*/
2425
private array $bindings = [];
2526

27+
public function __construct(private readonly ?ContainerInterface $container = null)
28+
{
29+
}
30+
2631
/**
2732
* Bind a listener factory into the container.
2833
*
2934
* @param non-empty-string $listenerName
30-
* @param Closure():object $binding
35+
* @param (Closure():object)|non-empty-string $binding
3136
*/
32-
public function bind(string $listenerName, Closure $binding): void
37+
public function bind(string $listenerName, Closure|string $binding): void
3338
{
39+
if (is_string($binding) && $this->container === null) {
40+
throw new ApplicationException('Cannot use a string listener binding without a PSR container.');
41+
}
42+
3443
$this->bindings[$listenerName] = $binding;
3544
}
3645

3746
public function get(string $listenerName): object
3847
{
39-
$factory = $this->bindings[$listenerName] ?? null;
48+
$binding = $this->bindings[$listenerName] ?? null;
4049

41-
if ($factory) {
42-
$listener = $factory();
50+
if ($binding instanceof Closure) {
51+
$listener = $binding();
4352
assert(is_object($listener), "Listener binding for {$listenerName} must return an object.");
4453
return $listener;
4554
}
4655

56+
if ($this->container) {
57+
$target = $binding ?? $listenerName;
58+
$listener = $this->container->get($target);
59+
assert(is_object($listener), "PSR container listener binding {$target} is not an object.");
60+
return $listener;
61+
}
62+
4763
throw new ApplicationException('Unrecognised listener name: ' . $listenerName);
4864
}
4965
}

src/Application/DomainEventDispatching/UnitOfWorkAwareDispatcher.php

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@
1717
use CloudCreativity\Modules\Contracts\Domain\Events\DomainEvent;
1818
use CloudCreativity\Modules\Contracts\Domain\Events\OccursImmediately;
1919
use CloudCreativity\Modules\Contracts\Toolkit\Pipeline\PipeContainer;
20+
use Psr\Container\ContainerInterface;
21+
use Psr\Log\LoggerInterface;
2022

2123
class UnitOfWorkAwareDispatcher extends Dispatcher
2224
{
2325
public function __construct(
2426
private readonly UnitOfWorkManager $unitOfWorkManager,
25-
IListenerContainer $listeners = new ListenerContainer(),
27+
ContainerInterface|IListenerContainer $listeners = new ListenerContainer(),
2628
?PipeContainer $middleware = null,
29+
private readonly ?LoggerInterface $logger = null,
2730
) {
2831
parent::__construct($listeners, $middleware);
2932
}
@@ -46,14 +49,30 @@ public function dispatch(DomainEvent $event): void
4649
protected function execute(DomainEvent $event, EventHandler $listener): void
4750
{
4851
if ($listener->beforeCommit()) {
49-
$this->unitOfWorkManager->beforeCommit(static function () use ($event, $listener): void {
52+
$this->logger?->debug('Deferring listener to be handled before commit.', [
53+
'event' => $event::class,
54+
'listener' => (string) $listener,
55+
]);
56+
$this->unitOfWorkManager->beforeCommit(function () use ($event, $listener): void {
57+
$this->logger?->debug('Executing listener before commit.', [
58+
'event' => $event::class,
59+
'listener' => (string) $listener,
60+
]);
5061
$listener($event);
5162
});
5263
return;
5364
}
5465

5566
if ($listener->afterCommit()) {
56-
$this->unitOfWorkManager->afterCommit(static function () use ($event, $listener): void {
67+
$this->logger?->debug('Deferring listener to be handled after commit.', [
68+
'event' => $event::class,
69+
'listener' => (string) $listener,
70+
]);
71+
$this->unitOfWorkManager->afterCommit(function () use ($event, $listener): void {
72+
$this->logger?->debug('Executing listener after commit.', [
73+
'event' => $event::class,
74+
'listener' => (string) $listener,
75+
]);
5776
$listener($event);
5877
});
5978
return;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* Copyright 2026 Cloud Creativity Limited
5+
*
6+
* Use of this source code is governed by an MIT-style
7+
* license that can be found in the LICENSE file or at
8+
* https://opensource.org/licenses/MIT.
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace CloudCreativity\Modules\Tests\Integration\Application\DomainEventDispatching;
14+
15+
use CloudCreativity\Modules\Application\DomainEventDispatching\ListenTo;
16+
use CloudCreativity\Modules\Application\DomainEventDispatching\Middleware\LogDomainEventDispatch;
17+
use CloudCreativity\Modules\Application\DomainEventDispatching\UnitOfWorkAwareDispatcher;
18+
use CloudCreativity\Modules\Toolkit\Pipeline\Through;
19+
20+
#[ListenTo(NumbersAdded::class, TestDomainListener::class)]
21+
#[ListenTo(NumbersSubtracted::class, [
22+
TestDomainListener::class,
23+
TestDomainListener::class,
24+
])]
25+
#[Through(LogDomainEventDispatch::class)]
26+
final class MathDomainEventDispatcher extends UnitOfWorkAwareDispatcher
27+
{
28+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
/*
4+
* Copyright 2026 Cloud Creativity Limited
5+
*
6+
* Use of this source code is governed by an MIT-style
7+
* license that can be found in the LICENSE file or at
8+
* https://opensource.org/licenses/MIT.
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace CloudCreativity\Modules\Tests\Integration\Application\DomainEventDispatching;
14+
15+
use CloudCreativity\Modules\Application\DomainEventDispatching\Middleware\LogDomainEventDispatch;
16+
use CloudCreativity\Modules\Application\UnitOfWork\UnitOfWorkManager;
17+
use CloudCreativity\Modules\Testing\FakeContainer;
18+
use PHPUnit\Framework\TestCase;
19+
20+
class MathDomainEventDispatcherTest extends TestCase
21+
{
22+
public function test(): void
23+
{
24+
$container = new FakeContainer();
25+
$listener = new TestDomainListener();
26+
$unitOfWorkManager = new UnitOfWorkManager($container->unitOfWork);
27+
28+
$container->bind(TestDomainListener::class, fn () => $listener);
29+
$container->bind(LogDomainEventDispatch::class, fn () => new LogDomainEventDispatch($container->logger));
30+
31+
$dispatcher = new MathDomainEventDispatcher(
32+
unitOfWorkManager: $unitOfWorkManager,
33+
listeners: $container,
34+
);
35+
36+
$add = new NumbersAdded(1, 2, 3);
37+
$subtract = new NumbersSubtracted(5, 4, 1);
38+
39+
$unitOfWorkManager->execute(function () use ($add, $subtract, $dispatcher) {
40+
$dispatcher->dispatch($add);
41+
$dispatcher->dispatch($subtract);
42+
});
43+
44+
$this->assertSame([
45+
$add,
46+
$subtract,
47+
$subtract,
48+
], $listener->events);
49+
$this->assertCount(4, $container->logger);
50+
}
51+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* Copyright 2026 Cloud Creativity Limited
5+
*
6+
* Use of this source code is governed by an MIT-style
7+
* license that can be found in the LICENSE file or at
8+
* https://opensource.org/licenses/MIT.
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace CloudCreativity\Modules\Tests\Integration\Application\DomainEventDispatching;
14+
15+
use CloudCreativity\Modules\Contracts\Domain\Events\DomainEvent;
16+
use CloudCreativity\Modules\Toolkit\Contracts;
17+
use DateTimeImmutable;
18+
19+
final readonly class NumbersAdded implements DomainEvent
20+
{
21+
public function __construct(
22+
public int $a,
23+
public int $b,
24+
public int $sum,
25+
public DateTimeImmutable $calculatedAt = new DateTimeImmutable(),
26+
) {
27+
Contracts::assert($sum === ($a + $b), 'The sum must be equal to a plus b.');
28+
}
29+
30+
public function getOccurredAt(): DateTimeImmutable
31+
{
32+
return $this->calculatedAt;
33+
}
34+
}

0 commit comments

Comments
 (0)