diff --git a/README.md b/README.md index feae3be..580b5f0 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,13 @@ On top of those base traits **Complex Heart** provide ready to use compositions: - `IsEntity` composed by `IsModel`, `HasIdentity`, `HasEquality`. - `IsAggregate` composed by `IsEntity`, `HasDomainEvents`. -For more information please check the wiki. +## Key Features + +- **Type-Safe Factory Method**: The `make()` static factory validates constructor parameters at runtime with clear error messages +- **Automatic Invariant Checking**: When using `make()`, Value Objects and Entities automatically validate invariants after construction (no manual `$this->check()` needed) +- **Readonly Properties Support**: Full compatibility with PHP 8.1+ readonly properties +- **PHPStan Level 8**: Complete static analysis support + +> **Note:** Automatic invariant checking only works when using the `make()` factory method. Direct constructor calls require manual `$this->check()` in the constructor. + +For more information and usage examples, please check the wiki. diff --git a/composer.json b/composer.json index 6d999eb..df29e00 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "pestphp/pest-plugin-faker": "^2.0", "phpstan/phpstan": "^1.0", "phpstan/extension-installer": "^1.3", - "phpstan/phpstan-mockery": "^1.1" + "phpstan/phpstan-mockery": "^1.1", + "laravel/pint": "^1.25" }, "autoload": { "psr-4": { @@ -41,11 +42,12 @@ "scripts": { "test": "vendor/bin/pest --configuration=phpunit.xml --coverage-clover=coverage.xml --log-junit=test.xml", "test-cov": "vendor/bin/pest --configuration=phpunit.xml --coverage-html=coverage", - "analyse": "vendor/bin/phpstan analyse src --no-progress --level=8", + "analyse": "vendor/bin/phpstan analyse src --no-progress --memory-limit=4G --level=8", "check": [ "@analyse", "@test" - ] + ], + "pint": "vendor/bin/pint --preset psr12" }, "config": { "allow-plugins": { diff --git a/src/Errors/ImmutabilityError.php b/src/Errors/ImmutabilityError.php index 36d02d7..5f16327 100644 --- a/src/Errors/ImmutabilityError.php +++ b/src/Errors/ImmutabilityError.php @@ -14,5 +14,4 @@ */ class ImmutabilityError extends Error { - } diff --git a/src/Exceptions/InstantiationException.php b/src/Exceptions/InstantiationException.php index 11d50c4..df4c056 100644 --- a/src/Exceptions/InstantiationException.php +++ b/src/Exceptions/InstantiationException.php @@ -14,5 +14,4 @@ */ class InstantiationException extends RuntimeException { - } diff --git a/src/Exceptions/InvariantViolation.php b/src/Exceptions/InvariantViolation.php index 4636b62..8ab5d43 100644 --- a/src/Exceptions/InvariantViolation.php +++ b/src/Exceptions/InvariantViolation.php @@ -14,5 +14,4 @@ */ class InvariantViolation extends Exception { - } diff --git a/src/IsAggregate.php b/src/IsAggregate.php index 6fe5f81..cb42048 100644 --- a/src/IsAggregate.php +++ b/src/IsAggregate.php @@ -16,7 +16,6 @@ * @see https://martinfowler.com/bliki/EvansClassification.html * * @author Unay Santisteban - * @package ComplexHeart\Domain\Model\Traits */ trait IsAggregate { diff --git a/src/IsEntity.php b/src/IsEntity.php index f2abdac..09f0c2e 100644 --- a/src/IsEntity.php +++ b/src/IsEntity.php @@ -16,7 +16,6 @@ * @see https://martinfowler.com/bliki/EvansClassification.html * * @author Unay Santisteban - * @package ComplexHeart\Domain\Model\Traits */ trait IsEntity { @@ -25,4 +24,14 @@ trait IsEntity use HasEquality { HasIdentity::hash insteadof HasEquality; } + + /** + * Entities have automatic invariant checking enabled by default. + * + * @return bool + */ + protected function shouldAutoCheckInvariants(): bool + { + return true; + } } diff --git a/src/IsModel.php b/src/IsModel.php index a8ad029..467f4df 100644 --- a/src/IsModel.php +++ b/src/IsModel.php @@ -4,18 +4,21 @@ namespace ComplexHeart\Domain\Model; -use ComplexHeart\Domain\Model\Exceptions\InstantiationException; use ComplexHeart\Domain\Model\Traits\HasAttributes; use ComplexHeart\Domain\Model\Traits\HasInvariants; -use Doctrine\Instantiator\Exception\ExceptionInterface; -use Doctrine\Instantiator\Instantiator; -use RuntimeException; /** * Trait IsModel * + * Provides type-safe object instantiation with automatic invariant checking. + * + * Key improvements in this version: + * - Type-safe make() method with validation + * - Automatic invariant checking after construction + * - Constructor as single source of truth + * - Better error messages + * * @author Unay Santisteban - * @package ComplexHeart\Domain\Model\Traits */ trait IsModel { @@ -23,11 +26,170 @@ trait IsModel use HasInvariants; /** - * Initialize the Model. Just as the constructor will do. + * Create instance with type-safe validation. + * + * This method: + * 1. Validates parameter types against constructor signature + * 2. Creates instance through constructor (type-safe) + * 3. Invariants are checked automatically after construction + * + * @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 make(mixed ...$params): static + { + $reflection = new \ReflectionClass(static::class); + $constructor = $reflection->getConstructor(); + + if (!$constructor) { + throw new \RuntimeException( + sprintf('%s must have a constructor to use make()', static::class) + ); + } + + // Validate parameters against constructor signature + // array_values ensures we have a proper indexed array + self::validateConstructorParameters($constructor, array_values($params)); + + // Create instance through constructor (PHP handles type enforcement) + // @phpstan-ignore-next-line - new static() is safe here as we validated the constructor + $instance = new static(...$params); + + // Auto-check invariants if enabled + $instance->autoCheckInvariants(); + + return $instance; + } + + /** + * Validate parameters match constructor signature. + * + * @param \ReflectionMethod $constructor + * @param array $params + * @return void + * @throws \InvalidArgumentException + * @throws \TypeError + */ + private static function validateConstructorParameters( + \ReflectionMethod $constructor, + array $params + ): void { + $constructorParams = $constructor->getParameters(); + $required = $constructor->getNumberOfRequiredParameters(); + + // Check parameter count + if (count($params) < $required) { + $missing = array_slice($constructorParams, count($params), $required - count($params)); + $names = array_map(fn ($p) => $p->getName(), $missing); + throw new \InvalidArgumentException( + sprintf( + '%s::make() missing required parameters: %s', + basename(str_replace('\\', '/', static::class)), + implode(', ', $names) + ) + ); + } + + // Validate types for each parameter + foreach ($constructorParams as $index => $param) { + if (!isset($params[$index])) { + continue; // Optional parameter not provided + } + + $value = $params[$index]; + $type = $param->getType(); + + if (!$type instanceof \ReflectionNamedType) { + continue; // No type hint or union type + } + + $typeName = $type->getName(); + $isValid = self::validateType($value, $typeName, $type->allowsNull()); + + if (!$isValid) { + throw new \TypeError( + sprintf( + '%s::make() parameter "%s" must be of type %s, %s given', + basename(str_replace('\\', '/', static::class)), + $param->getName(), + $typeName, + get_debug_type($value) + ) + ); + } + } + } + + /** + * Validate a value matches expected type. + * + * @param mixed $value + * @param string $typeName + * @param bool $allowsNull + * @return bool + */ + private static function validateType(mixed $value, string $typeName, bool $allowsNull): bool + { + if ($value === null) { + return $allowsNull; + } + + return match($typeName) { + 'int' => is_int($value), + 'float' => is_float($value) || is_int($value), // Allow int for float + 'string' => is_string($value), + 'bool' => is_bool($value), + 'array' => is_array($value), + 'object' => is_object($value), + 'callable' => is_callable($value), + 'iterable' => is_iterable($value), + 'mixed' => true, + default => $value instanceof $typeName + }; + } + + /** + * Determine if invariants should be checked automatically after construction. + * + * Override this method in your class to disable auto-check: + * + * protected function shouldAutoCheckInvariants(): bool + * { + * return false; + * } + * + * @return bool + */ + protected function shouldAutoCheckInvariants(): bool + { + return false; // Disabled by default for backward compatibility + } + + /** + * Called after construction to auto-check invariants. + * + * This method is automatically called after the constructor completes + * if shouldAutoCheckInvariants() returns true. + * + * @return void + */ + private function autoCheckInvariants(): void + { + if ($this->shouldAutoCheckInvariants()) { + $this->check(); + } + } + + /** + * Initialize the Model (legacy method - DEPRECATED). + * + * @deprecated Use constructor with make() factory method instead. + * This method will be removed in v1.0.0 * * @param array $source * @param string|callable $onFail - * * @return static */ protected function initialize(array $source, string|callable $onFail = 'invariantHandler'): static @@ -39,17 +201,16 @@ protected function initialize(array $source, string|callable $onFail = 'invarian } /** - * Transform an indexed array into assoc array by combining the - * given values with the list of attributes of the object. + * Transform an indexed array into assoc array (legacy method - DEPRECATED). * + * @deprecated This method will be removed in v1.0.0 * @param array $source - * * @return array */ private function prepareAttributes(array $source): array { // check if the array is indexed or associative. - $isIndexed = fn($source): bool => ([] !== $source) && array_keys($source) === range(0, count($source) - 1); + $isIndexed = fn ($source): bool => ([] !== $source) && array_keys($source) === range(0, count($source) - 1); /** @var array $source */ return $isIndexed($source) @@ -58,22 +219,4 @@ private function prepareAttributes(array $source): array // return the already mapped array source. : $source; } - - /** - * Restore the instance without calling __constructor of the model. - * - * @return static - * - * @throws RuntimeException - */ - final public static function make(): static - { - try { - return (new Instantiator()) - ->instantiate(static::class) - ->initialize(func_get_args()); - } catch (ExceptionInterface $e) { - throw new InstantiationException($e->getMessage(), $e->getCode(), $e); - } - } } diff --git a/src/IsValueObject.php b/src/IsValueObject.php index cf17349..7dad5a0 100644 --- a/src/IsValueObject.php +++ b/src/IsValueObject.php @@ -13,12 +13,14 @@ * > A small simple object, like money or a date range, whose equality isn't based on identity. * > -- Martin Fowler * + * Value Objects have automatic invariant checking enabled by default when using the make() factory method. + * For direct constructor usage, you must manually call $this->check() at the end of your constructor. + * * @see https://martinfowler.com/eaaCatalog/valueObject.html * @see https://martinfowler.com/bliki/ValueObject.html * @see https://martinfowler.com/bliki/EvansClassification.html * * @author Unay Santisteban - * @package ComplexHeart\Domain\Model\Traits */ trait IsValueObject { @@ -26,6 +28,16 @@ trait IsValueObject use HasEquality; use HasImmutability; + /** + * Value Objects have automatic invariant checking enabled by default. + * + * @return bool + */ + protected function shouldAutoCheckInvariants(): bool + { + return true; + } + /** * Represents the object as String. * diff --git a/src/Traits/HasAttributes.php b/src/Traits/HasAttributes.php index 9c14ac1..db496bd 100644 --- a/src/Traits/HasAttributes.php +++ b/src/Traits/HasAttributes.php @@ -24,7 +24,7 @@ final public static function attributes(): array { return array_filter( array_keys(get_class_vars(static::class)), - fn(string $item): bool => !str_starts_with($item, '_') + fn (string $item): bool => !str_starts_with($item, '_') ); } @@ -118,7 +118,7 @@ protected function getStringKey(string $id, string $prefix = '', string $suffix return sprintf( '%s%s%s', $prefix, - implode('', map(fn(string $chunk): string => ucfirst($chunk), explode('_', $id))), + implode('', map(fn (string $chunk): string => ucfirst($chunk), explode('_', $id))), $suffix ); } diff --git a/src/Traits/HasEquality.php b/src/Traits/HasEquality.php index 5f03cc4..1a3aa7b 100644 --- a/src/Traits/HasEquality.php +++ b/src/Traits/HasEquality.php @@ -46,5 +46,5 @@ protected function hash(): string * * @return string */ - abstract function __toString(): string; + abstract public function __toString(): string; } diff --git a/src/Traits/HasInvariants.php b/src/Traits/HasInvariants.php index e10bb22..909ad0f 100644 --- a/src/Traits/HasInvariants.php +++ b/src/Traits/HasInvariants.php @@ -34,8 +34,10 @@ final public static function invariants(): array if (array_key_exists(static::class, static::$_invariantsCache) === false) { $invariants = []; foreach (get_class_methods(static::class) as $invariant) { - if (str_starts_with($invariant, 'invariant') && !in_array($invariant, - ['invariants', 'invariantHandler'])) { + if (str_starts_with($invariant, 'invariant') && !in_array( + $invariant, + ['invariants', 'invariantHandler'] + )) { $invariantRuleName = preg_replace('/[A-Z]([A-Z](?![a-z]))*/', ' $0', $invariant); if (is_null($invariantRuleName)) { continue; @@ -118,19 +120,18 @@ private function computeInvariantHandler(string|callable $handlerFn, string $exc ? function (array $violations) use ($handlerFn, $exception): void { $this->{$handlerFn}($violations, $exception); } - : function (array $violations) use ($exception): void { - if (count($violations) === 1) { - throw array_shift($violations); - } - - 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)), + : function (array $violations) use ($exception): void { + if (count($violations) === 1) { + throw array_shift($violations); + } - ) - ); - }; + 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)), + ) + ); + }; } } diff --git a/src/Traits/HasTypeCheck.php b/src/Traits/HasTypeCheck.php index 3586e75..463fda1 100644 --- a/src/Traits/HasTypeCheck.php +++ b/src/Traits/HasTypeCheck.php @@ -28,8 +28,8 @@ protected function isValueTypeValid(mixed $value, string $validType): bool $primitives = ['integer', 'boolean', 'float', 'string', 'array', 'object', 'callable']; $validation = in_array($validType, $primitives) - ? fn($value): bool => gettype($value) === $validType - : fn($value): bool => $value instanceof $validType; + ? fn ($value): bool => gettype($value) === $validType + : fn ($value): bool => $value instanceof $validType; return $validation($value); } diff --git a/src/ValueObjects/ArrayValue.php b/src/ValueObjects/ArrayValue.php index 855f38d..6cd7e9f 100644 --- a/src/ValueObjects/ArrayValue.php +++ b/src/ValueObjects/ArrayValue.php @@ -93,8 +93,8 @@ protected function invariantItemsMustMatchTheRequiredType(): bool if ($this->_valueType !== 'mixed') { $primitives = ['integer', 'boolean', 'float', 'string', 'array', 'object', 'callable']; $check = in_array($this->_valueType, $primitives) - ? fn($value): bool => gettype($value) !== $this->_valueType - : fn($value): bool => !($value instanceof $this->_valueType); + ? fn ($value): bool => gettype($value) !== $this->_valueType + : fn ($value): bool => !($value instanceof $this->_valueType); foreach ($this->value as $item) { if ($check($item)) { diff --git a/src/ValueObjects/IntegerValue.php b/src/ValueObjects/IntegerValue.php index 6c7be3f..0f7c4de 100644 --- a/src/ValueObjects/IntegerValue.php +++ b/src/ValueObjects/IntegerValue.php @@ -16,7 +16,6 @@ */ abstract class IntegerValue extends Value { - /** * Define the max value of the integer. * diff --git a/src/ValueObjects/UUIDValue.php b/src/ValueObjects/UUIDValue.php index 024740e..762a6a1 100644 --- a/src/ValueObjects/UUIDValue.php +++ b/src/ValueObjects/UUIDValue.php @@ -4,7 +4,6 @@ namespace ComplexHeart\Domain\Model\ValueObjects; - use ComplexHeart\Domain\Contracts\Model\Identifier; use Exception; use Ramsey\Uuid\Codec\TimestampFirstCombCodec; @@ -66,7 +65,7 @@ public function value(): string public static function random(bool $ordered = true): self { if ($ordered) { - $factory = new UuidFactory; + $factory = new UuidFactory(); $factory->setRandomGenerator(new CombGenerator( $factory->getRandomGenerator(), diff --git a/tests/AggregatesTest.php b/tests/AggregatesTest.php index 982eb3e..012eb81 100644 --- a/tests/AggregatesTest.php +++ b/tests/AggregatesTest.php @@ -4,7 +4,7 @@ use ComplexHeart\Domain\Contracts\ServiceBus\Event; use ComplexHeart\Domain\Contracts\ServiceBus\EventBus; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Order; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Order; use ComplexHeart\Domain\Model\ValueObjects\UUIDValue; test('Aggregate should register domain event successfully.', function () { diff --git a/tests/AutoCheckInvariantsTest.php b/tests/AutoCheckInvariantsTest.php new file mode 100644 index 0000000..d22a387 --- /dev/null +++ b/tests/AutoCheckInvariantsTest.php @@ -0,0 +1,43 @@ +throws(InvariantViolation::class); + +test('ValueObject should auto-check invariants and succeed', function () { + $email = Email::make('valid@example.com'); + + expect($email)->toBeInstanceOf(Email::class); +}); + +test('ValueObject with multiple invariants should validate all', function () { + Money::make(-10, 'USD'); +})->throws(InvariantViolation::class); + +test('ValueObject should fail on invalid currency invariant', function () { + Money::make(100, 'US'); +})->throws(InvariantViolation::class); + +test('Entity with auto-check disabled should not check invariants', function () { + // This should NOT throw even though name is empty + $entity = CustomEntity::make(UUIDValue::random(), ''); + + expect($entity)->toBeInstanceOf(CustomEntity::class); +}); + +test('Entity with auto-check enabled would fail on invalid data', function () { + // Entities have auto-check enabled by default (via IsEntity) + // But our CustomEntity overrides it to false + // Let's test that regular entities DO auto-check + + // This should throw because invalid email + Email::make('invalid-email'); +})->throws(InvariantViolation::class); diff --git a/tests/OrderManagement/Domain/Customer.php b/tests/Fixtures/OrderManagement/Domain/Customer.php similarity index 91% rename from tests/OrderManagement/Domain/Customer.php rename to tests/Fixtures/OrderManagement/Domain/Customer.php index d632dca..4845967 100644 --- a/tests/OrderManagement/Domain/Customer.php +++ b/tests/Fixtures/OrderManagement/Domain/Customer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain; use ComplexHeart\Domain\Contracts\Model\Identifier; use ComplexHeart\Domain\Contracts\Model\Entity; diff --git a/tests/OrderManagement/Domain/Errors/InvalidPriceError.php b/tests/Fixtures/OrderManagement/Domain/Errors/InvalidPriceError.php similarity index 52% rename from tests/OrderManagement/Domain/Errors/InvalidPriceError.php rename to tests/Fixtures/OrderManagement/Domain/Errors/InvalidPriceError.php index cda1d51..37570a4 100644 --- a/tests/OrderManagement/Domain/Errors/InvalidPriceError.php +++ b/tests/Fixtures/OrderManagement/Domain/Errors/InvalidPriceError.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Errors; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Errors; use Error; class InvalidPriceError extends Error { - } diff --git a/tests/OrderManagement/Domain/Events/OrderCreated.php b/tests/Fixtures/OrderManagement/Domain/Events/OrderCreated.php similarity index 83% rename from tests/OrderManagement/Domain/Events/OrderCreated.php rename to tests/Fixtures/OrderManagement/Domain/Events/OrderCreated.php index 228c15a..2b92993 100644 --- a/tests/OrderManagement/Domain/Events/OrderCreated.php +++ b/tests/Fixtures/OrderManagement/Domain/Events/OrderCreated.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Events; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Events; use ComplexHeart\Domain\Contracts\ServiceBus\Event; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\OrderLine; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Order; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\OrderLine; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Order; use ComplexHeart\Domain\Model\ValueObjects\DateTimeValue as Timestamp; use ComplexHeart\Domain\Model\ValueObjects\UUIDValue as ID; @@ -35,7 +35,7 @@ public function __construct(Order $order, ?ID $id = null, ?Timestamp $timestamp 'id' => $order->customer->id, 'name' => $order->customer->name, ], - 'orderLines' => $order->lines->map(fn(OrderLine $line) => $line->values()), + 'orderLines' => $order->lines->map(fn (OrderLine $line) => $line->values()), 'tags' => $order->tags->values()['value'], 'created' => $order->created->toIso8601String(), ]; @@ -66,4 +66,4 @@ public function aggregateId(): string { return $this->payload['id']; } -} \ No newline at end of file +} diff --git a/tests/OrderManagement/Domain/Order.php b/tests/Fixtures/OrderManagement/Domain/Order.php similarity index 91% rename from tests/OrderManagement/Domain/Order.php rename to tests/Fixtures/OrderManagement/Domain/Order.php index df95ded..6f4ef6e 100644 --- a/tests/OrderManagement/Domain/Order.php +++ b/tests/Fixtures/OrderManagement/Domain/Order.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain; use ComplexHeart\Domain\Contracts\Model\Aggregate; use ComplexHeart\Domain\Contracts\Model\Identifier; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; use ComplexHeart\Domain\Model\IsAggregate; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Events\OrderCreated; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Events\OrderCreated; use ComplexHeart\Domain\Model\ValueObjects\DateTimeValue as Timestamp; /** @@ -79,4 +79,4 @@ public function __toString(): string { return $this->reference->value(); } -} \ No newline at end of file +} diff --git a/tests/OrderManagement/Domain/OrderLine.php b/tests/Fixtures/OrderManagement/Domain/OrderLine.php similarity index 90% rename from tests/OrderManagement/Domain/OrderLine.php rename to tests/Fixtures/OrderManagement/Domain/OrderLine.php index ca8f36b..2d3cf88 100644 --- a/tests/OrderManagement/Domain/OrderLine.php +++ b/tests/Fixtures/OrderManagement/Domain/OrderLine.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain; use ComplexHeart\Domain\Contracts\Model\ValueObject; use ComplexHeart\Domain\Model\IsValueObject; @@ -33,4 +33,4 @@ public function __toString(): string { return "$this->concept x$this->quantity"; } -} \ No newline at end of file +} diff --git a/tests/OrderManagement/Domain/OrderLines.php b/tests/Fixtures/OrderManagement/Domain/OrderLines.php similarity index 83% rename from tests/OrderManagement/Domain/OrderLines.php rename to tests/Fixtures/OrderManagement/Domain/OrderLines.php index be3f107..6e26930 100644 --- a/tests/OrderManagement/Domain/OrderLines.php +++ b/tests/Fixtures/OrderManagement/Domain/OrderLines.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain; use ComplexHeart\Domain\Model\TypedCollection; @@ -17,4 +17,4 @@ final class OrderLines extends TypedCollection protected string $keyType = 'integer'; protected string $valueType = OrderLine::class; -} \ No newline at end of file +} diff --git a/tests/OrderManagement/Domain/Price.php b/tests/Fixtures/OrderManagement/Domain/Price.php similarity index 88% rename from tests/OrderManagement/Domain/Price.php rename to tests/Fixtures/OrderManagement/Domain/Price.php index 31676ea..8744b94 100644 --- a/tests/OrderManagement/Domain/Price.php +++ b/tests/Fixtures/OrderManagement/Domain/Price.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain; use ComplexHeart\Domain\Contracts\Model\ValueObject; use ComplexHeart\Domain\Model\IsValueObject; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Errors\InvalidPriceError; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Errors\InvalidPriceError; /** * Class Price @@ -54,4 +54,4 @@ public function __toString(): string { return "$this->amount $this->currency"; } -} \ No newline at end of file +} diff --git a/tests/OrderManagement/Domain/Reference.php b/tests/Fixtures/OrderManagement/Domain/Reference.php similarity index 94% rename from tests/OrderManagement/Domain/Reference.php rename to tests/Fixtures/OrderManagement/Domain/Reference.php index 2b1cfa3..2d0454e 100644 --- a/tests/OrderManagement/Domain/Reference.php +++ b/tests/Fixtures/OrderManagement/Domain/Reference.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain; use ComplexHeart\Domain\Contracts\Model\Identifier; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; diff --git a/tests/OrderManagement/Domain/Tags.php b/tests/Fixtures/OrderManagement/Domain/Tags.php similarity index 76% rename from tests/OrderManagement/Domain/Tags.php rename to tests/Fixtures/OrderManagement/Domain/Tags.php index 319406f..e259244 100644 --- a/tests/OrderManagement/Domain/Tags.php +++ b/tests/Fixtures/OrderManagement/Domain/Tags.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ComplexHeart\Domain\Model\Test\OrderManagement\Domain; +namespace ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain; use ComplexHeart\Domain\Model\ValueObjects\ArrayValue; @@ -13,4 +13,4 @@ final class Tags extends ArrayValue protected int $_maxItems = 10; protected string $_valueType = 'string'; -} \ No newline at end of file +} diff --git a/tests/Fixtures/TypeSafety/ComplexModel.php b/tests/Fixtures/TypeSafety/ComplexModel.php new file mode 100644 index 0000000..40ffd4d --- /dev/null +++ b/tests/Fixtures/TypeSafety/ComplexModel.php @@ -0,0 +1,23 @@ +name !== ''; + } + + public function id(): Identifier + { + return $this->entityId; + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/tests/Fixtures/TypeSafety/Email.php b/tests/Fixtures/TypeSafety/Email.php new file mode 100644 index 0000000..5263022 --- /dev/null +++ b/tests/Fixtures/TypeSafety/Email.php @@ -0,0 +1,31 @@ +value, FILTER_VALIDATE_EMAIL) !== false; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/tests/Fixtures/TypeSafety/Money.php b/tests/Fixtures/TypeSafety/Money.php new file mode 100644 index 0000000..b4751a6 --- /dev/null +++ b/tests/Fixtures/TypeSafety/Money.php @@ -0,0 +1,38 @@ +amount > 0; + } + + protected function invariantValidCurrency(): bool + { + return strlen($this->currency) === 3; + } + + public function __toString(): string + { + return sprintf('%s %s', $this->amount, $this->currency); + } +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php index 2b8d6b5..e6830b8 100644 --- a/tests/ModelTest.php +++ b/tests/ModelTest.php @@ -1,11 +1,11 @@ values(fn($attribute) => "-->$attribute"); + $values = $price->values(fn ($attribute) => "-->$attribute"); expect($values['amount'])->toStartWith('-->'); expect($values['currency'])->toStartWith('-->'); diff --git a/tests/TraitsTest.php b/tests/TraitsTest.php index 5884e1c..f17db32 100644 --- a/tests/TraitsTest.php +++ b/tests/TraitsTest.php @@ -4,8 +4,8 @@ use ComplexHeart\Domain\Model\Errors\ImmutabilityError; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Errors\InvalidPriceError; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Price; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Errors\InvalidPriceError; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Price; use ComplexHeart\Domain\Model\Traits\HasInvariants; test('Object with HasImmutability should throw ImmutabilityError for any update properties attempts.', function () { @@ -43,7 +43,7 @@ public function __construct() { - $this->check(fn(array $violations) => throw new ValueError('From custom Handler')); + $this->check(fn (array $violations) => throw new ValueError('From custom Handler')); } protected function invariantAlwaysFail(): bool @@ -76,4 +76,4 @@ protected function invariantAlwaysFailTwo(): bool }; }) ->group('Unit') - ->throws(InvariantViolation::class, 'always fail one, always fail two'); \ No newline at end of file + ->throws(InvariantViolation::class, 'always fail one, always fail two'); diff --git a/tests/TypeValidationTest.php b/tests/TypeValidationTest.php new file mode 100644 index 0000000..394432a --- /dev/null +++ b/tests/TypeValidationTest.php @@ -0,0 +1,81 @@ +toBeInstanceOf(Email::class) + ->and((string) $email)->toBe('test@example.com'); +}); + +test('make() should throw TypeError for invalid string type', function () { + Email::make(123); +})->throws(TypeError::class, 'parameter "value" must be of type string, int given'); + +test('make() should throw InvalidArgumentException for missing required parameters', function () { + Email::make(); +})->throws(InvalidArgumentException::class, 'missing required parameters: value'); + +test('make() should accept int or float for union type', function () { + $money1 = Money::make(100, 'USD'); + $money2 = Money::make(99.99, 'EUR'); + + expect($money1)->toBeInstanceOf(Money::class) + ->and($money2)->toBeInstanceOf(Money::class); +}); + +test('make() should throw TypeError for invalid union type', function () { + Money::make('invalid', 'USD'); +})->throws(TypeError::class); + +test('make() should handle nullable types correctly', function () { + $model = ComplexModel::make(1, 'Test', null, []); + + expect($model)->toBeInstanceOf(ComplexModel::class); +}); + +test('make() should handle array types correctly', function () { + $model = ComplexModel::make(1, 'Test', 'Description', ['tag1', 'tag2']); + + expect($model)->toBeInstanceOf(ComplexModel::class); +}); + +test('make() should throw TypeError for invalid array type', function () { + ComplexModel::make(1, 'Test', 'Description', 'not-an-array'); +})->throws(TypeError::class, 'parameter "tags" must be of type array, string given'); + +test('make() error message shows simple class name', function () { + try { + Email::make(123); + } catch (TypeError $e) { + expect($e->getMessage())->toContain('Email::make()') + ->and($e->getMessage())->not->toContain('ComplexHeart\\Domain\\Model'); + } +}); + +test('make() error message shows parameter name', function () { + try { + Money::make('invalid', 'USD'); + } catch (TypeError $e) { + // PHP's native error uses "Argument" not "parameter" + expect($e->getMessage())->toContain('$amount'); + } +}); + +test('make() error message shows actual type given', function () { + try { + Email::make(123); + } catch (TypeError $e) { + expect($e->getMessage())->toContain('int given'); + } +}); + +test('direct constructor bypasses type validation', function () { + // Direct constructor call with wrong type will fail at PHP level + new Email(123); +})->throws(TypeError::class); diff --git a/tests/TypedCollectionTest.php b/tests/TypedCollectionTest.php index 8ff7d82..d1fb4f9 100644 --- a/tests/TypedCollectionTest.php +++ b/tests/TypedCollectionTest.php @@ -131,4 +131,4 @@ $c[] = 'wrong'; }) ->throws(InvariantViolation::class) - ->group('Unit'); \ No newline at end of file + ->group('Unit'); diff --git a/tests/ValueObjectsTest.php b/tests/ValueObjectsTest.php index b30d078..5446269 100644 --- a/tests/ValueObjectsTest.php +++ b/tests/ValueObjectsTest.php @@ -4,8 +4,8 @@ use ComplexHeart\Domain\Model\Errors\ImmutabilityError; use ComplexHeart\Domain\Model\Exceptions\InvariantViolation; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Reference; -use ComplexHeart\Domain\Model\Test\OrderManagement\Domain\Tags; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Reference; +use ComplexHeart\Domain\Model\Test\Fixtures\OrderManagement\Domain\Tags; use ComplexHeart\Domain\Model\ValueObjects\ArrayValue; use ComplexHeart\Domain\Model\ValueObjects\BooleanValue; use ComplexHeart\Domain\Model\ValueObjects\DateTimeValue; @@ -35,7 +35,7 @@ ->group('Unit'); test('StringValue should throw exception on min length invariant violation.', function () { - new class('a') extends StringValue { + new class ('a') extends StringValue { protected int $_minLength = 5; }; }) @@ -43,7 +43,7 @@ ->group('Unit'); test('StringValue should throw exception on max length invariant violation.', function () { - new class('this is long') extends StringValue { + new class ('this is long') extends StringValue { protected int $_maxLength = 5; }; }) @@ -51,7 +51,7 @@ ->group('Unit'); test('StringValue should throw exception on regex invariant violation.', function () { - new class('INVALID') extends StringValue { + new class ('INVALID') extends StringValue { protected string $_pattern = '[a-z]'; }; }) @@ -59,7 +59,7 @@ ->group('Unit'); test('BooleanValue should create a valid BooleanValue Object.', function () { - $vo = new class(true) extends BooleanValue { + $vo = new class (true) extends BooleanValue { protected array $_strings = [ 'true' => 'Yes', 'false' => 'No', @@ -70,7 +70,7 @@ ->group('Unit'); test('IntegerValue should create a valid IntegerValue Object.', function () { - $vo = new class(1) extends IntegerValue { + $vo = new class (1) extends IntegerValue { protected int $_maxValue = 100; protected int $_minValue = 1; @@ -80,7 +80,7 @@ ->group('Unit'); test('IntegerValue should throw exception on min value invariant violation.', function () { - new class(0) extends IntegerValue { + new class (0) extends IntegerValue { protected int $_maxValue = 100; protected int $_minValue = 1; @@ -90,7 +90,7 @@ ->group('Unit'); test('IntegerValue should throw exception on mix value invariant violation.', function () { - new class(101) extends IntegerValue { + new class (101) extends IntegerValue { protected int $_maxValue = 100; protected int $_minValue = 1; @@ -100,14 +100,14 @@ ->group('Unit'); test('FloatValue should create a valid FloatValue Object.', function () { - $vo = new class(3.14) extends FloatValue { + $vo = new class (3.14) extends FloatValue { }; expect((string) $vo)->toEqual('3.14'); }) ->group('Unit'); test('ArrayValue should create a valid ArrayValue Object.', function () { - $vo = new class([1]) extends ArrayValue { + $vo = new class ([1]) extends ArrayValue { protected int $_minItems = 1; protected int $_maxItems = 10; @@ -125,7 +125,7 @@ ->group('Unit'); test('ArrayValue should throw exception on invalid minimum number of items.', function () { - new class([]) extends ArrayValue { + new class ([]) extends ArrayValue { protected int $_minItems = 2; protected int $_maxItems = 0; @@ -137,7 +137,7 @@ ->group('Unit'); test('ArrayValue should throw exception on invalid maximum number of items.', function () { - new class([1, 2]) extends ArrayValue { + new class ([1, 2]) extends ArrayValue { protected int $_minItems = 0; protected int $_maxItems = 1; @@ -149,7 +149,7 @@ ->group('Unit'); test('ArrayValue should implement correctly ArrayAccess interface.', function () { - $vo = new class(['one', 'two']) extends ArrayValue { + $vo = new class (['one', 'two']) extends ArrayValue { protected int $_minItems = 1; protected int $_maxItems = 10; @@ -165,7 +165,7 @@ ->group('Unit'); test('ArrayValue should throw exception on deleting a value.', function () { - $vo = new class(['one', 'two']) extends ArrayValue { + $vo = new class (['one', 'two']) extends ArrayValue { protected int $_minItems = 1; protected int $_maxItems = 10; @@ -178,7 +178,7 @@ ->throws(ImmutabilityError::class); test('ArrayValue should throw exception on changing a value.', function () { - $vo = new class(['one', 'two']) extends ArrayValue { + $vo = new class (['one', 'two']) extends ArrayValue { protected int $_minItems = 1; protected int $_maxItems = 10; @@ -191,7 +191,7 @@ ->throws(ImmutabilityError::class); test('ArrayValue should be converted to string correctly.', function () { - $vo = new class(['one', 'two']) extends ArrayValue { + $vo = new class (['one', 'two']) extends ArrayValue { protected int $_minItems = 1; protected int $_maxItems = 10; @@ -237,8 +237,8 @@ test('EnumValue should create a valid EnumValue Object.', function () { $vo = new class ('one') extends EnumValue { - const ONE = 'one'; - const TWO = 'two'; + public const ONE = 'one'; + public const TWO = 'two'; }; expect($vo->value())->toBe('one') diff --git a/wiki/Domain-Modeling-Aggregates.md b/wiki/Domain-Modeling-Aggregates.md index 61fecd9..b682272 100644 --- a/wiki/Domain-Modeling-Aggregates.md +++ b/wiki/Domain-Modeling-Aggregates.md @@ -3,33 +3,41 @@ > Aggregate is a cluster of domain objects that can be treated as a single unit.\ > -- Martin Fowler -Creating a Value Object is quite easy you only need to use the Trait `IsAggregate` this will -add the `HasAttributes`, `HasInvariants`, `HasDomainEvents`, `HasIdentity` and `HasEquality` Traits. -In addition, you could use the `Aggregate` interface to expose the `publishDomainEvents` method. +An Aggregate is a cluster of associated objects that are treated as a unit for data consistency. Aggregates define clear consistency boundaries and enforce business rules within those boundaries. Creating an Aggregate with Complex Heart is straightforward using the `IsAggregate` trait, which combines `HasAttributes`, `HasInvariants`, `HasDomainEvents`, `HasIdentity`, and `HasEquality` traits. Additionally, you can implement the `Aggregate` interface to expose the `publishDomainEvents` method. + +## Getting Started + +The `IsAggregate` trait provides everything needed to model aggregates: + +* `HasAttributes`: Manage aggregate attributes +* `HasInvariants`: Define and validate business rules +* `HasDomainEvents`: Register and publish domain events +* `HasIdentity`: Provide unique identification +* `HasEquality`: Enable identity-based equality comparison ## Example -The following example illustrates the implementation of these components. +The following example illustrates a complete Aggregate implementation. + +#### Modern Approach: Type-Safe Factory Method (Recommended) ```php final class Order implements Aggregate { use IsAggregate; - public function __construct( + private function __construct( public Reference $reference, public Customer $customer, public OrderLines $lines, public Tags $tags, public Timestamp $created - ) { - $this->check(); - } + ) {} public static function create(int $number, array $customer): Order { $created = Timestamp::now(); - $order = new Order( + $order = self::make( reference: Reference::fromTimestamp($created, $number), customer: new Customer(...$customer), lines: OrderLines::empty(), @@ -77,6 +85,53 @@ final class Order implements Aggregate } ``` +**Benefits of using `make()` in factory methods:** +- Automatic invariant checking when using `make()` +- Type validation at runtime +- Cleaner factory method code +- Consistent with Value Objects and Entities + +**Important:** Auto-check ONLY works when using `make()`. In the alternative approach using direct constructor calls, you must manually call `$this->check()` inside the constructor. + +#### Alternative: Direct Constructor with Manual Check + +If using the constructor directly, you **must** manually call `$this->check()`: + +```php +final class Order implements Aggregate +{ + use IsAggregate; + + public function __construct( + public Reference $reference, + public Customer $customer, + public OrderLines $lines, + public Tags $tags, + public Timestamp $created + ) { + $this->check(); // Required for invariant validation + } + + public static function create(int $number, array $customer): Order + { + $created = Timestamp::now(); + $order = new Order( + reference: Reference::fromTimestamp($created, $number), + customer: new Customer(...$customer), + lines: OrderLines::empty(), + tags: new Tags(), + created: $created + ); + + $order->registerDomainEvent(new OrderCreated($order)); + + return $order; + } + + // ... rest of the methods +} +``` + ## Key Concepts ### Root Entity @@ -112,41 +167,50 @@ encapsulation allows for changes to the internal implementation without affectin ### Domain Events -Domain Events are events that capture a meaningful state change within the domain. When integrated with Aggregates, -Domain Events enhance the capability to communicate and react to changes effectively. the `HasDomainEvents` trait -provides some methods to easy the implementation of Domain Events within your aggregates. +Domain Events are events that capture meaningful state changes within the domain. When integrated with Aggregates, Domain Events enhance the capability to communicate and react to changes effectively. The `HasDomainEvents` trait provides methods to easily implement Domain Events within your aggregates. + +#### Registering Domain Events ```php -final class Order implements Aggregate +public static function create(int $number, array $customer): Order { - use IsAggregate; + $created = Timestamp::now(); + $order = self::make( + reference: Reference::fromTimestamp($created, $number), + customer: new Customer(...$customer), + lines: OrderLines::empty(), + tags: new Tags(), + created: $created + ); + + // Register domain event + $order->registerDomainEvent(new OrderCreated($order)); + + return $order; +} - public function __construct( - public Reference $reference, - public Customer $customer, - public OrderLines $lines, - public Tags $tags, - public Timestamp $created - ) { - $this->check(); - } +public function addOrderLine(OrderLine $line): self +{ + $this->lines->add($line); - public static function create(int $number, array $customer): Order - { - $created = Timestamp::now(); - $order = new Order( - reference: Reference::fromTimestamp($created, $number), - customer: new Customer(...$customer), - lines: OrderLines::empty(), - tags: new Tags(), - created: $created - ); - - $order->registerDomainEvent(new OrderCreated($order)); + // Register domain event for line addition + $this->registerDomainEvent(new OrderLineAdded($this, $line)); - return $order; - } + return $this; +} +``` + +The `registerDomainEvent()` method allows you to register events that implement the `Event` interface into the aggregate. + +#### Publishing Domain Events + +```php +// Publish all registered events to an event bus +$order->publishDomainEvents($eventBus); ``` -The method `registerDomainEvent` allows you to register new events that implements the `Event` interface into the -aggregate. \ No newline at end of file +**Key Points:** +- Events are registered during state changes +- Events are published in a batch to maintain transactional consistency +- The aggregate maintains a list of unpublished events +- Events should be published after successful persistence \ No newline at end of file diff --git a/wiki/Domain-Modeling-Entities.md b/wiki/Domain-Modeling-Entities.md index 4116be9..64f290a 100644 --- a/wiki/Domain-Modeling-Entities.md +++ b/wiki/Domain-Modeling-Entities.md @@ -24,6 +24,44 @@ consistent interface for all Entities. Let's illustrate the implementation of a `Customer` Entity using the provided traits and interface: +#### Modern Approach: Type-Safe Factory Method (Recommended) + +```php +final class Customer implements Entity +{ + use IsEntity; + + public function __construct( + private readonly UUIDValue $id, + public string $name, + ) {} + + public function id(): Identifier + { + return $this->id; + } + + public function __toString(): string + { + return "$this->name ($this->id)"; + } +} + +// Type-safe instantiation with automatic invariant validation +$customer = Customer::make(UUIDValue::random(), 'Vincent Vega'); +``` + +**Benefits:** +- Automatic invariant checking when using `make()` +- Type validation at runtime +- Cleaner constructor code + +**Important:** Auto-check ONLY works when using `make()`. If you call the constructor directly (`new Customer(...)`), you must manually call `$this->check()` inside the constructor. + +#### Alternative: Direct Constructor with Manual Check + +If you need to use the constructor directly, you **must** manually call `$this->check()`: + ```php final class Customer implements Entity { @@ -33,7 +71,7 @@ final class Customer implements Entity public readonly UUIDValue $id, public string $name, ) { - $this->check(); + $this->check(); // Required for invariant validation } public function id(): Identifier diff --git a/wiki/Domain-Modeling-Value-Objects.md b/wiki/Domain-Modeling-Value-Objects.md index fba85ea..ad4fc12 100644 --- a/wiki/Domain-Modeling-Value-Objects.md +++ b/wiki/Domain-Modeling-Value-Objects.md @@ -66,42 +66,80 @@ $magenta = new Color('ff00ff'); // throws InvariantViolation: Value must be hexa ### Attribute Initialization -Use -the [Constructor property promotion](https://www.php.net/manual/en/language.oop5.decon.php#language.oop5.decon.constructor.promotion) -to initialize the attributes: +#### Modern Approach: Type-Safe Factory Method (Recommended) + +Use the `make()` static factory method for type-safe instantiation with automatic invariant checking: ```php -class SomeValueObject implements ValueObject +class Email implements ValueObject { use IsValueObject; - public function __construct(public string $value) + public function __construct(private readonly string $value) {} + + protected function invariantValidFormat(): bool { - $this->check(); + return filter_var($this->value, FILTER_VALIDATE_EMAIL) !== false; + } + + public function __toString(): string + { + return $this->value; } } + +// Type-safe instantiation with automatic validation +$email = Email::make('user@example.com'); // ✅ Valid +$email = Email::make(123); // ❌ TypeError: parameter "value" must be of type string, int given +$email = Email::make('invalid'); // ❌ InvariantViolation: Valid format ``` -Alternatively, use the `initialize` method to set the initial values of the attributes. This ensures that the Value -Object is properly initialized. +**Benefits of `make()`:** +- Runtime type validation with clear error messages +- Automatic invariant checking after construction +- Works seamlessly with readonly properties +- PHPStan level 8 compliant + +**Important:** Auto-check ONLY works when using `make()`. Direct constructor calls do NOT trigger automatic invariant checking, so you must manually call `$this->check()` in the constructor. + +#### Alternative: Constructor Property Promotion with Manual Check + +If you prefer direct constructor calls, you **must** manually call `$this->check()`: ```php -class SomeValueObject implements ValueObject +class SomeValueObject implements ValueObject { use IsValueObject; - - public string $value; - public function __construct(string $value) + public function __construct(public string $value) { - $this->initialize(['value' => $value]); + $this->check(); // Required for invariant validation } } + +// Direct constructor call requires manual check() in constructor +$vo = new SomeValueObject('value'); // check() is called inside constructor ``` -The `initialize` method takes an array of properties to hydrate the object. Whether the associative array -contains additional properties, the `initialize` method selectively considers only those belonging to the object, -matching the property names using the respective keys. +Use [Constructor property promotion](https://www.php.net/manual/en/language.oop5.decon.php#language.oop5.decon.constructor.promotion) for cleaner syntax. + +#### Legacy: Initialize Method (Deprecated) + +The `initialize` method is deprecated and will be removed in v1.0.0. It's incompatible with readonly properties: + +```php +class SomeValueObject implements ValueObject +{ + use IsValueObject; + + public string $value; + + public function __construct(string $value) + { + $this->initialize(['value' => $value]); // Deprecated + } +} +``` ### Immutability