Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/Contracts/Aggregatable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace ComplexHeart\Domain\Model\Contracts;

/**
* Interface Aggregatable
*
* Marker interface for exceptions that can be aggregated during invariant validation.
*
* Exceptions implementing this interface will be collected and aggregated when
* multiple invariants fail. Exceptions NOT implementing this interface will be
* thrown immediately, stopping invariant checking.
*
* @author Unay Santisteban <usantisteban@othercode.io>
* @package ComplexHeart\Domain\Model\Contracts
*/
interface Aggregatable
{
}
65 changes: 64 additions & 1 deletion src/Exceptions/InvariantViolation.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace ComplexHeart\Domain\Model\Exceptions;

use ComplexHeart\Domain\Model\Contracts\Aggregatable;
use Exception;

/**
Expand All @@ -12,6 +13,68 @@
* @author Unay Santisteban <usantisteban@othercode.io>
* @package ComplexHeart\Domain\Model\Exceptions
*/
class InvariantViolation extends Exception
class InvariantViolation extends Exception implements Aggregatable
{
/**
* @var array<int, string> List of all violation messages
*/
private array $violations = [];

/**
* Create an invariant violation exception from one or more violations.
*
* @param array<int, string> $violations
* @param int $code
* @param Exception|null $previous
* @return self
*/
public static function fromViolations(array $violations, int $code = 0, ?Exception $previous = null): self
{
$count = count($violations);

// Format message based on count
if ($count === 1) {
$message = $violations[0];
} else {
$message = sprintf(
"Multiple invariant violations (%d):\n- %s",
$count,
implode("\n- ", $violations)
);
}

$exception = new self($message, $code, $previous);
$exception->violations = $violations;
return $exception;
}

/**
* Check if this exception has multiple violations.
*
* @return bool
*/
public function hasMultipleViolations(): bool
{
return count($this->violations) > 1;
}

/**
* Get all violation messages.
*
* @return array<int, string>
*/
public function getViolations(): array
{
return $this->violations;
}

/**
* Get the count of violations.
*
* @return int
*/
public function getViolationCount(): int
{
return count($this->violations);
}
}
17 changes: 16 additions & 1 deletion src/IsModel.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ final public static function make(mixed ...$params): static
return $instance;
}

/**
* Alias for make() method - more idiomatic for domain objects.
*
* Example: Customer::new(id: $id, name: 'John Doe')
*
* @param mixed ...$params Constructor parameters
* @return static
* @throws InvalidArgumentException When required parameters are missing
* @throws TypeError When parameter types don't match
*/
final public static function new(mixed ...$params): static
{
return static::make(...$params);
}

/**
* Validate parameters match constructor signature.
*
Expand Down Expand Up @@ -128,7 +143,7 @@ private static function validateConstructorParameters(
// Union type (e.g., int|float|string)
$isValid = self::validateUnionType($value, $type);
$expectedTypes = implode('|', array_map(
fn($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
fn ($t) => $t instanceof ReflectionNamedType ? $t->getName() : 'mixed',
$type->getTypes()
));
} else {
Expand Down
58 changes: 48 additions & 10 deletions src/Traits/HasInvariants.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace ComplexHeart\Domain\Model\Traits;

use ComplexHeart\Domain\Model\Contracts\Aggregatable;
use ComplexHeart\Domain\Model\Exceptions\InvariantViolation;
use Throwable;

Expand Down Expand Up @@ -110,6 +111,15 @@ private function computeInvariantViolations(string $exception): array
return $violations;
}

/**
* Compute the invariant handler function.
*
* The handler is responsible for throwing exceptions (single or aggregated).
*
* @param string|callable $handlerFn
* @param string $exception
* @return callable
*/
private function computeInvariantHandler(string|callable $handlerFn, string $exception): callable
{
if (!is_string($handlerFn)) {
Expand All @@ -121,17 +131,45 @@ private function computeInvariantHandler(string|callable $handlerFn, string $exc
$this->{$handlerFn}($violations, $exception);
}
: function (array $violations) use ($exception): void {
if (count($violations) === 1) {
throw array_shift($violations);
$this->throwInvariantViolations($violations, $exception);
};
}

/**
* Throw invariant violations (single or aggregated).
*
* Responsible for all exception throwing logic:
* - Non-aggregatable exceptions: throw the first one immediately
* - Aggregatable exceptions: aggregate and throw as InvariantViolation
*
* @param array<string, Throwable> $violations
* @param string $exception
* @return void
* @throws Throwable
*/
private function throwInvariantViolations(array $violations, string $exception): void
{
// Separate aggregatable from non-aggregatable violations
$aggregatable = [];
$nonAggregatable = [];

foreach ($violations as $key => $violation) {
if ($violation instanceof Aggregatable) {
$aggregatable[$key] = $violation;
} else {
$nonAggregatable[$key] = $violation;
}
}

throw new $exception( // @phpstan-ignore-line
sprintf(
"Unable to create %s due: %s",
basename(str_replace('\\', '/', static::class)),
implode(", ", map(fn (Throwable $e): string => $e->getMessage(), $violations)),
)
);
};
// If there are non-aggregatable exceptions, throw the first one immediately
if (!empty($nonAggregatable)) {
throw array_shift($nonAggregatable);
}

// All violations are aggregatable - aggregate them
if (!empty($aggregatable)) {
$messages = map(fn (Throwable $e): string => $e->getMessage(), $aggregatable);
throw InvariantViolation::fromViolations(array_values($messages));
}
}
}
Loading