Identifiers
phil/ddd-bundlePhil\DddBundle\Domain\Identifier\Identifier is the abstract base of every aggregate identifier. It wraps a
UUIDv7 from symfony/uid.
Declaring an identifier
One empty subclass per aggregate:
namespace App\Ordering\Domain;
use Phil\DddBundle\Domain\Identifier\Identifier;
final readonly class OrderId extends Identifier
{
}
The constructor is final private: an identifier is only ever obtained through generate(), fromString() or fromUuid(),
and a subclass cannot add state or change how it is built.
API
| Member | Returns | Behaviour |
|---|---|---|
public UuidV7 $value | The wrapped UUID, read-only | |
static generate() | static | A new identifier with a fresh UUIDv7, of the subclass it is called on |
static fromString(string $value) | static | Parses a UUID string; throws InvalidValue when it is not a UUID, or is a UUID of another version |
static fromUuid(Uuid $uuid) | static | Wraps a UUID already parsed, without printing it and parsing it again; throws InvalidValue when it is not a v7. IdentifierType builds an identifier read from a binary column this way |
equals(Identifier $other) | bool | True only when both are of the same class and carry the same UUID |
toString() | string | The canonical RFC 4122 form, lowercase: 0192f3c4-8a1e-7b2c-9d3e-4f5a6b7c8d9e |
__toString() | string | Same as toString() |
$id = OrderId::generate();
$same = OrderId::fromString($id->toString());
$id->equals($same); // true
$id->equals(CustomerId::fromString($id->toString())); // false: another identifier class
OrderId::fromString('philippe'); // InvalidValue: not a UUID
OrderId::fromString(Uuid::v4()->toRfc4122()); // InvalidValue: a UUID, but not a v7
Design decisions
- UUIDv7, never v4. A v7 is time-ordered: in a B-tree index, a new key lands at the tail instead of splitting
pages at random, and the identifier carries its own creation time (
$id->value->getDateTime()). A v4 is refused byfromString()even though it is a valid UUID, because admitting it would let into storage identifiers that have neither property. The nil UUID is refused too. - Generated by the caller, not by the database. The code that sends a command generates the identifier and
puts it in the command. The handler can then return
void, and no return value has to cross the transaction boundary of the command bus (see command-and-query-buses.md). - Equality is by class and value. Two identifiers of different aggregates that happen to carry the same UUID are not equal. Without this, an account identifier could pass an equality test against a session identifier.
generate()returnsstatic.OrderId::generate()is anOrderId, so typed code downstream stays typed.- It is the internal representation, and nothing more. Whatever leaves the system (a token, a public URL, an export) has a form the application decides. The class has no presentation method and will not get one.
Invariants
- The value is always a
Symfony\Component\Uid\UuidV7. toString()always matches/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.X::fromString($id->toString())->equals($id)holds for every identifier$idof classX.- An identifier never sorts before one generated earlier in the same process, as a string or as bytes.
Storing identifiers
As a string column, toString() and fromString() are enough. With Doctrine DBAL, the bundle's
IdentifierType stores them in 16 bytes, or in the platform's native UUID column; see
doctrine-identifier-type.md.