Skip to content

Commit 288e663

Browse files
committed
Feature/implement aggregated exception handling for invariant violations
1 parent 733d738 commit 288e663

File tree

5 files changed

+165
-25
lines changed

5 files changed

+165
-25
lines changed

src/Exceptions/InvariantViolation.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,16 @@
44

55
namespace ComplexHeart\Domain\Model\Exceptions;
66

7+
use ComplexHeart\Domain\Model\Contracts\Aggregatable;
78
use Exception;
89

910
/**
1011
* Class InvariantViolation
1112
*
12-
* Exception thrown when one or more invariants are violated.
13-
*
1413
* @author Unay Santisteban <usantisteban@othercode.io>
1514
* @package ComplexHeart\Domain\Model\Exceptions
1615
*/
17-
class InvariantViolation extends Exception
16+
class InvariantViolation extends Exception implements Aggregatable
1817
{
1918
/**
2019
* @var array<int, string> List of all violation messages

src/IsModel.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ private static function validateConstructorParameters(
143143
// Union type (e.g., int|float|string)
144144
$isValid = self::validateUnionType($value, $type);
145145
$expectedTypes = implode('|', array_map(
146-
fn($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
146+
fn ($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
147147
$type->getTypes()
148148
));
149149
} else {

src/Traits/HasInvariants.php

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace ComplexHeart\Domain\Model\Traits;
66

7+
use ComplexHeart\Domain\Model\Contracts\Aggregatable;
78
use ComplexHeart\Domain\Model\Exceptions\InvariantViolation;
89
use Throwable;
910

@@ -110,6 +111,15 @@ private function computeInvariantViolations(string $exception): array
110111
return $violations;
111112
}
112113

114+
/**
115+
* Compute the invariant handler function.
116+
*
117+
* The handler is responsible for throwing exceptions (single or aggregated).
118+
*
119+
* @param string|callable $handlerFn
120+
* @param string $exception
121+
* @return callable
122+
*/
113123
private function computeInvariantHandler(string|callable $handlerFn, string $exception): callable
114124
{
115125
if (!is_string($handlerFn)) {
@@ -121,24 +131,45 @@ private function computeInvariantHandler(string|callable $handlerFn, string $exc
121131
$this->{$handlerFn}($violations, $exception);
122132
}
123133
: function (array $violations) use ($exception): void {
124-
// Always aggregate violations for InvariantViolation
125-
if ($exception === InvariantViolation::class) {
126-
$messages = map(fn (Throwable $e): string => $e->getMessage(), $violations);
127-
throw InvariantViolation::fromViolations(array_values($messages));
128-
}
134+
$this->throwInvariantViolations($violations, $exception);
135+
};
136+
}
129137

130-
// Legacy behavior for custom exception classes
131-
if (count($violations) === 1) {
132-
throw array_shift($violations);
138+
/**
139+
* Throw invariant violations (single or aggregated).
140+
*
141+
* Responsible for all exception throwing logic:
142+
* - Non-aggregatable exceptions: throw the first one immediately
143+
* - Aggregatable exceptions: aggregate and throw as InvariantViolation
144+
*
145+
* @param array<string, Throwable> $violations
146+
* @param string $exception
147+
* @return void
148+
* @throws Throwable
149+
*/
150+
private function throwInvariantViolations(array $violations, string $exception): void
151+
{
152+
// Separate aggregatable from non-aggregatable violations
153+
$aggregatable = [];
154+
$nonAggregatable = [];
155+
156+
foreach ($violations as $key => $violation) {
157+
if ($violation instanceof Aggregatable) {
158+
$aggregatable[$key] = $violation;
159+
} else {
160+
$nonAggregatable[$key] = $violation;
133161
}
162+
}
134163

135-
throw new $exception( // @phpstan-ignore-line
136-
sprintf(
137-
"Unable to create %s due: %s",
138-
basename(str_replace('\\', '/', static::class)),
139-
implode(", ", map(fn (Throwable $e): string => $e->getMessage(), $violations)),
140-
)
141-
);
142-
};
164+
// If there are non-aggregatable exceptions, throw the first one immediately
165+
if (!empty($nonAggregatable)) {
166+
throw array_shift($nonAggregatable);
167+
}
168+
169+
// All violations are aggregatable - aggregate them
170+
if (!empty($aggregatable)) {
171+
$messages = map(fn (Throwable $e): string => $e->getMessage(), $aggregatable);
172+
throw InvariantViolation::fromViolations(array_values($messages));
173+
}
143174
}
144175
}

tests/TraitsTest.php

Lines changed: 114 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
declare(strict_types=1);
44

5+
use ComplexHeart\Domain\Model\Contracts\Aggregatable;
56
use ComplexHeart\Domain\Model\Errors\ImmutabilityError;
67
use ComplexHeart\Domain\Model\Exceptions\InvariantViolation;
78
use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Errors\InvalidPriceError;
@@ -17,17 +18,17 @@
1718

1819
test('Object with HasImmutability should expose primitive values.', function () {
1920
$price = new Price(100.0, 'EUR');
20-
expect($price->amount)->toBeFloat();
21-
expect($price->currency)->toBeString();
21+
expect($price->amount)->toBeFloat()
22+
->and($price->currency)->toBeString();
2223
})
2324
->group('Unit');
2425

2526
test('Object with HasImmutability should return new instance with override values.', function () {
2627
$price = new Price(100.0, 'EUR');
2728
$newPrice = $price->applyDiscount(10.0);
2829

29-
expect($newPrice)->toBeInstanceOf(Price::class);
30-
expect($newPrice->amount)->toBe(90.0);
30+
expect($newPrice)->toBeInstanceOf(Price::class)
31+
->and($newPrice->amount)->toBe(90.0);
3132
})
3233
->group('Unit');
3334

@@ -165,3 +166,112 @@ protected function invariantSingleFailure(): bool
165166
->and($exception->getMessage())->toBe('Single error message');
166167
})
167168
->group('Unit');
169+
170+
test('Custom non-aggregatable exception should be thrown immediately', function () {
171+
new class () {
172+
use HasInvariants;
173+
174+
public function __construct()
175+
{
176+
$this->check();
177+
}
178+
179+
protected function invariantCustomError(): bool
180+
{
181+
throw new DomainException('Custom domain error');
182+
}
183+
};
184+
})
185+
->group('Unit')
186+
->throws(DomainException::class, 'Custom domain error');
187+
188+
test('Custom non-aggregatable exception stops invariant checking', function () {
189+
new class () {
190+
use HasInvariants;
191+
192+
public function __construct()
193+
{
194+
$this->check();
195+
}
196+
197+
protected function invariantFirstCheck(): bool
198+
{
199+
// This should throw immediately
200+
throw new RuntimeException('First error');
201+
}
202+
203+
protected function invariantSecondCheck(): bool
204+
{
205+
// This should NEVER be reached
206+
throw new DomainException('Second error - should not be reached');
207+
}
208+
};
209+
})
210+
->group('Unit')
211+
->throws(RuntimeException::class, 'First error');
212+
213+
test('Custom aggregatable exception should be aggregated', function () {
214+
try {
215+
new class () {
216+
use HasInvariants;
217+
218+
public function __construct()
219+
{
220+
$this->check();
221+
}
222+
223+
protected function invariantFirst(): bool
224+
{
225+
// Create aggregatable exception inline
226+
$exception = new class ('Aggregatable error') extends \Exception implements Aggregatable {};
227+
throw $exception;
228+
}
229+
230+
protected function invariantSecond(): bool
231+
{
232+
return false; // Regular InvariantViolation
233+
}
234+
};
235+
} catch (InvariantViolation $e) {
236+
expect($e->hasMultipleViolations())->toBeTrue()
237+
->and($e->getViolationCount())->toBe(2)
238+
->and($e->getViolations())->toContain('Aggregatable error')
239+
->and($e->getViolations())->toContain('second');
240+
}
241+
})
242+
->group('Unit');
243+
244+
test('InvariantViolation implements Aggregatable', function () {
245+
$exception = InvariantViolation::fromViolations(['Test']);
246+
247+
expect($exception)->toBeInstanceOf(Aggregatable::class);
248+
})
249+
->group('Unit');
250+
251+
test('Mix of custom non-aggregatable throws immediately before aggregation', function () {
252+
new class () {
253+
use HasInvariants;
254+
255+
public function __construct()
256+
{
257+
$this->check();
258+
}
259+
260+
protected function invariantFirstAggregatable(): bool
261+
{
262+
return false; // InvariantViolation
263+
}
264+
265+
protected function invariantCustomNonAggregatable(): bool
266+
{
267+
throw new RuntimeException('Non-aggregatable error');
268+
}
269+
270+
protected function invariantThirdAggregatable(): bool
271+
{
272+
return false; // Should not be reached
273+
}
274+
};
275+
})
276+
->group('Unit')
277+
->throws(RuntimeException::class, 'Non-aggregatable error');

tests/ValueObjectsTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
protected string $_pattern = '[a-z]';
5656
};
5757
})
58-
->throws(InvariantViolation::class)
58+
->throws(InvalidArgumentException::class) // Non-aggregatable, thrown immediately
5959
->group('Unit');
6060

6161
test('BooleanValue should create a valid BooleanValue Object.', function () {

0 commit comments

Comments
 (0)