Skip to content

Identifiers

phil/ddd-bundle

Phil\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

MemberReturnsBehaviour
public UuidV7 $valueThe wrapped UUID, read-only
static generate()staticA new identifier with a fresh UUIDv7, of the subclass it is called on
static fromString(string $value)staticParses a UUID string; throws InvalidValue when it is not a UUID, or is a UUID of another version
static fromUuid(Uuid $uuid)staticWraps 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)boolTrue only when both are of the same class and carry the same UUID
toString()stringThe canonical RFC 4122 form, lowercase: 0192f3c4-8a1e-7b2c-9d3e-4f5a6b7c8d9e
__toString()stringSame 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 by fromString() 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() returns static. OrderId::generate() is an OrderId, 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 $id of class X.
  • 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.