Using id value objects for better readability and type safety

Using id value objects for better readability and type safety
Photo by Francesca Tirico / Unsplash

When I stared my development career, the usual identifiers for any entity was an unsigned integer. After a while this was replaced by UUIDs which limit the amount of syncing necessary and meant that it's possible to fill an entity with all data including the id without the help of the database. Only one issue remaining:

It's always possible to supply the wrong id as a parameter!

UPDATE: I've released a package that offers the base for ids and id lists. It has reached the first stable version and offers even easier doctrine types than shown here.

Value object

But that can be solved by custom id value objects. For those, we setup one abstract base id class:

<?php

declare(strict_types=1);

namespace App\ValueObject;

use App\ValueObject\Exception\InvalidId;
use Assert\Assertion;
use Assert\AssertionFailedException;
use Psalm\Immutable;
use Ramsey\Uuid\Uuid;

#[Immutable]
abstract class BaseId implements \Stringable
{
    public string $id;

    // Construction

    final public function __construct(string $id) 
    {
        if (!Uuid::isValid($id)) {
            throw new InvalidId($id);
        }

        $this->id = $id;
    }

    final public static function generateRandom(): static
    {
        $id = Uuid::uuid4()->toString();

        return new static($id);
    }

    final public static function fromString(string $id): static
    {
        return new static($id);
    }

    // Magic

    public function __toString(): string
    {
        return $this->id;
    }

    // Accessors

    public function isEqualTo(self $id): bool
    {
        return $this->id === $id->id;
    }

    public function isNotEqualTo(self $id): bool
    {
        return $this->id !== $id->id;
    }

    // Guards

    /** @throws AssertionFailedException */
    public function mustBeEqualTo(self $id): void
    {
        Assertion::true($this->isEqualTo($id), 'Is not same id');
    }

    /** @throws AssertionFailedException */
    public function mustNotBeEqualTo(self $id): void
    {
        Assertion::true($this->isNotEqualTo($id), 'Is same id');
    }
}

An implementation for it is then as simple as the following:

<?php

declare(strict_types=1);

namespace App\ValueObject;

use Psalm\Immutable;

#[Immutable]
final class UserId extends BaseId
{
}

Doctrine type

We need a custom doctrine type to be able to use this object in the Doctrine entities. We're also using an abstract class as a base:

<?php

declare(strict_types=1);

namespace App\Doctrine;

use App\ValueObject\BaseId;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

abstract class IdValueObjectType extends Type
{
    protected const TYPE = 'id_value_object';

    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
    {
        return $platform->getGuidTypeDeclarationSQL($column);
    }

    /** @param ?BaseId $value */
    public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
    {
        if ($value === null) {
            return null;
        }

        return (string) $value;
    }

    public function getName(): string
    {
        /** @var string */
        return static::TYPE;
    }

    public function requiresSQLCommentHint(AbstractPlatform $platform): bool
    {
        return true;
    }
}

Our implementation for each class is just the following:

<?php

declare(strict_types=1);

namespace App\Doctrine;

use App\ValueObject\UserId;
use Doctrine\DBAL\Platforms\AbstractPlatform;

final class UserIdType extends IdValueObjectType
{
    protected const TYPE = 'user_id';

    /** @param ?string $value */
    public function convertToPHPValue($value, AbstractPlatform $platform): ?UserId
    {
        if ($value === null) {
            return null;
        }

        return UserId::fromString($value);
    }
}

Each id type must be registered with doctrine in the relevant configuration:

doctrine:
  dbal:
    types:
      user_id: App\Doctrine\UserIdType
      ...

After that, we can use it in an entity like this:

<?php

declare(strict_types=1);

namespace App\Entity;

use App\ValueObject\UserId;
use Doctrine\ORM\Mapping as ORM;
use Psalm\Readonly;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: '`user`')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\Column(name: 'id', type: 'user_id')]
    public UserId $id;
    
    ...
}

Serialization

The last place were we might want to use it is serialization. In the projects I'm working, we use the CQRS bundle to create command objects through the Symfony serializer.

Luckily we get all information for the object creation with the way the normalizer interfaces are structured. Therefor we only need one normalizer to handle all id instances like the following:

<?php

declare(strict_types=1);

namespace App\Serializer;

use App\ValueObject\BaseId;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final class IdNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface
{
    public function supportsNormalization($data, $format = null): bool
    {
        return $data instanceof BaseId;
    }

    /** @param class-string $type */
    public function supportsDenormalization($data, $type, $format = null): bool
    {
        return is_subclass_of($type, BaseId::class);
    }

    public function normalize($object, $format = null, array $context = []): string
    {
        /** @var BaseId $object */
        return $object->id;
    }

    /**
     * @param string $data
     * @psalm-param class-string<BaseId> $type
     *
     * @return BaseId
     */
    public function denormalize($data, $type, $format = null, array $context = [])
    {
        /** @noinspection PhpUndefinedMethodInspection */
        return $type::fromString($data);
    }

    public function hasCacheableSupportsMethod(): bool
    {
        return true;
    }
}

Depending on your configuration you won't need to register it as it's picked up  and registered through the interface by Symfony. You just have to make sure that there isn't another normalizer which handles the ids before this one will. You might need to adapt it by registering it with a different priority:

services:
  App\Serializer\IdNormalizer:
    tags: [ { name: 'serializer.normalizer', priority: 90 } ]

This way you should be able to write command objects like the following and have it be constructed through the Symfony serializer:

<?php

declare(strict_types=1);

namespace App\Domain\News\WriteSide\CreateNewsArticle;

use App\ValueObject\UserId;
use DigitalCraftsman\CQRS\Command\Command;
use Psalm\Immutable;

#[Immutable]
final class CreateNewsArticleCommand implements Command
{
    public function __construct(
        public UserId $userId,
        public string $title,
        public string $content,
    ) {
    }
}

Conclusion

Having setup those components, you can now use the value objects in the whole codebase without having to manually translate them anywhere. With added type safety everywhere you use them.