Skip to content

Commit 6dc84ee

Browse files
authored
Feature/invariant violation aggregation (#23)
* aggregate invariant violations and enhance InvariantViolation exception handling * implement aggregated exception handling for invariant violations
1 parent c744ad9 commit 6dc84ee

File tree

7 files changed

+389
-18
lines changed

7 files changed

+389
-18
lines changed

src/Contracts/Aggregatable.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace ComplexHeart\Domain\Model\Contracts;
6+
7+
/**
8+
* Interface Aggregatable
9+
*
10+
* Marker interface for exceptions that can be aggregated during invariant validation.
11+
*
12+
* Exceptions implementing this interface will be collected and aggregated when
13+
* multiple invariants fail. Exceptions NOT implementing this interface will be
14+
* thrown immediately, stopping invariant checking.
15+
*
16+
* @author Unay Santisteban <usantisteban@othercode.io>
17+
* @package ComplexHeart\Domain\Model\Contracts
18+
*/
19+
interface Aggregatable
20+
{
21+
}

src/Exceptions/InvariantViolation.php

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace ComplexHeart\Domain\Model\Exceptions;
66

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

910
/**
@@ -12,6 +13,68 @@
1213
* @author Unay Santisteban <usantisteban@othercode.io>
1314
* @package ComplexHeart\Domain\Model\Exceptions
1415
*/
15-
class InvariantViolation extends Exception
16+
class InvariantViolation extends Exception implements Aggregatable
1617
{
18+
/**
19+
* @var array<int, string> List of all violation messages
20+
*/
21+
private array $violations = [];
22+
23+
/**
24+
* Create an invariant violation exception from one or more violations.
25+
*
26+
* @param array<int, string> $violations
27+
* @param int $code
28+
* @param Exception|null $previous
29+
* @return self
30+
*/
31+
public static function fromViolations(array $violations, int $code = 0, ?Exception $previous = null): self
32+
{
33+
$count = count($violations);
34+
35+
// Format message based on count
36+
if ($count === 1) {
37+
$message = $violations[0];
38+
} else {
39+
$message = sprintf(
40+
"Multiple invariant violations (%d):\n- %s",
41+
$count,
42+
implode("\n- ", $violations)
43+
);
44+
}
45+
46+
$exception = new self($message, $code, $previous);
47+
$exception->violations = $violations;
48+
return $exception;
49+
}
50+
51+
/**
52+
* Check if this exception has multiple violations.
53+
*
54+
* @return bool
55+
*/
56+
public function hasMultipleViolations(): bool
57+
{
58+
return count($this->violations) > 1;
59+
}
60+
61+
/**
62+
* Get all violation messages.
63+
*
64+
* @return array<int, string>
65+
*/
66+
public function getViolations(): array
67+
{
68+
return $this->violations;
69+
}
70+
71+
/**
72+
* Get the count of violations.
73+
*
74+
* @return int
75+
*/
76+
public function getViolationCount(): int
77+
{
78+
return count($this->violations);
79+
}
1780
}

src/IsModel.php

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ final public static function make(mixed ...$params): static
7575
return $instance;
7676
}
7777

78+
/**
79+
* Alias for make() method - more idiomatic for domain objects.
80+
*
81+
* Example: Customer::new(id: $id, name: 'John Doe')
82+
*
83+
* @param mixed ...$params Constructor parameters
84+
* @return static
85+
* @throws InvalidArgumentException When required parameters are missing
86+
* @throws TypeError When parameter types don't match
87+
*/
88+
final public static function new(mixed ...$params): static
89+
{
90+
return static::make(...$params);
91+
}
92+
7893
/**
7994
* Validate parameters match constructor signature.
8095
*
@@ -128,7 +143,7 @@ private static function validateConstructorParameters(
128143
// Union type (e.g., int|float|string)
129144
$isValid = self::validateUnionType($value, $type);
130145
$expectedTypes = implode('|', array_map(
131-
fn($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
146+
fn ($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
132147
$type->getTypes()
133148
));
134149
} else {

src/Traits/HasInvariants.php

Lines changed: 48 additions & 10 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,17 +131,45 @@ private function computeInvariantHandler(string|callable $handlerFn, string $exc
121131
$this->{$handlerFn}($violations, $exception);
122132
}
123133
: function (array $violations) use ($exception): void {
124-
if (count($violations) === 1) {
125-
throw array_shift($violations);
134+
$this->throwInvariantViolations($violations, $exception);
135+
};
136+
}
137+
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;
126161
}
162+
}
127163

128-
throw new $exception( // @phpstan-ignore-line
129-
sprintf(
130-
"Unable to create %s due: %s",
131-
basename(str_replace('\\', '/', static::class)),
132-
implode(", ", map(fn (Throwable $e): string => $e->getMessage(), $violations)),
133-
)
134-
);
135-
};
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+
}
136174
}
137175
}

0 commit comments

Comments
 (0)