Read models with native queries and the Symfony serializer

Read models with native queries and the Symfony serializer
Photo by Vlado Paunovic / Unsplash

I'm using CQRS in all my projects. It enables me to have processes that are very easy to understand and are independent from each other.

As every endpoint has separate read models, it's also possible to improve a single endpoint without affecting any other. One possible improvement is to use a native query and construct the read model from the query result instead of pulling doctrine entities and converting them into read models "manually".

When doing so, there are two stumbling blocks:

  1. Mapping of query result data to read models.
  2. Decoding of query result data (as it's all strings).

Mapping of query result data to read models.

There are multiple mapping solutions for PHP, but most of them need some kind of custom mapping for each read model. Instead of this, I'm using the Symfony serializer. It's already in use for the conversion of API requests and responses. Additionally I have custom normalizers that handle a varity of value objects. Why not use the same for the conversion from a query result to a read model?

I'm just wrapping the Symfony denormalizer into a class that handles the type for an array and use template annotations to add more typing. But even that's optional.

<?php

declare(strict_types=1);

namespace App\Serializer;

use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

final readonly class Denormalizer
{
    public function __construct(
        private DenormalizerInterface $denormalizer,
    ) {
    }

    /**
     * @template T of object
     *
     * @param class-string<T> $class
     *
     * @return array<int, T>
     */
    public function denormalizeArray(
        array $data,
        string $class,
    ): array {
        /** @var array<int, T> */
        return $this->denormalizer->denormalize(
            data: $data,
            type: self::arrayOfClass($class),
        );
    }

    public static function arrayOfClass(string $class): string
    {
        return sprintf('%s[]', $class);
    }
}

Decoding of query result data

When using Connection->fetchAllAssociative(), the query result will be an associative array with all colums, but all values (except booleans and integers) will be strings due to the underlying pg_fetch_assoc.

We can solve that by wrapping the connection into a class that directly does the decoding depending on a specific configuration for each query. This way we can convert a JSON string into an associative array that can then be directly denormalized to a read model.

<?php

declare(strict_types=1);

namespace App\Serializer;

use Doctrine\DBAL\Connection;

final readonly class DecoderConnectionWrapper
{
    public function __construct(
        private Connection $connection,
    ) {
    }

    /** @param array<string, DTO\DecoderOption> $decoderOptions */
    public function fetchAllAssociative(
        string $query,
        array $params = [],
        array $types = [],
        array $decoderOptions = [],
    ): array {
        $result = $this->connection->fetchAllAssociative($query, $params, $types);

        return self::decodeResults($result, $decoderOptions);
    }

    /**
     * @param array<int, array<string, mixed> $data
     * @param array<string, DTO\DecoderOption> $decoderOptions
     */
    public static function decodeResults(
        array $data,
        array $decoderOptions,
    ): array {
        $decodedData = [];
        foreach ($data as $item) {
            $decodedData[] = self::decodeItem($item, $decoderOptions);
        }

        return $decodedData;
    }

    /** @param array<string, DTO\DecoderOption> $decoderOptions */
    private static function decodeItem(
        array $item,
        array $decoderOptions,
    ): array {
        $relevantKeys = array_keys($decoderOptions);

        $decodedItem = [];
        foreach ($item as $itemKey => $itemValue) {
            if (!in_array($itemKey, $relevantKeys, true)) {
                $decodedItem[$itemKey] = $itemValue;
                continue;
            }

            $decoderOption = $decoderOptions[$itemKey];
            $decodedItem[$itemKey] = match ($decoderOption) {
                DTO\DecoderOption::INT => (int) $itemValue,
                DTO\DecoderOption::NULLABLE_INT => $itemValue === null
                    ? null
                    : (int) $itemValue,
                DTO\DecoderOption::FLOAT => (float) $itemValue,
                DTO\DecoderOption::NULLABLE_FLOAT => $itemValue === null
                    ? null
                    : (float) $itemValue,
                DTO\DecoderOption::JSON => json_decode(
                    $itemValue,
                    true,
                    512,
                    \JSON_THROW_ON_ERROR,
                ),
                DTO\DecoderOption::NULLABLE_JSON => $itemValue === null
                    ? null
                    : json_decode(
                        $itemValue,
                        true,
                        512,
                        \JSON_THROW_ON_ERROR,
                    ),
                DTO\DecoderOption::JSON_WITH_EMPTY_ARRAY_ON_NULL => $itemValue === null
                    ? []
                    : json_decode(
                        $itemValue,
                        true,
                        512,
                        \JSON_THROW_ON_ERROR,
                    ),
            };
        }

        return $decodedItem;
    }
}

DecoderConnectionWrapper.php

Putting it together

Using those pieces, we could get the following result:

<?php

declare(strict_types=1);

namespace App\Domain\UserManagement\ReadSide\GetUsersWithAddresses;

final readonly class GetUsersWithAddressesQueryHandler implements QueryHandler
{
    public function __construct(
        private UserCollection $userCollection,
        private GetUsersWithAddressesDataProvider $getUsersWithAddressesDataProvider,
    ) {
    }

    /** @return array<int, ReadModel\UserWithAddresses> **/
    public function __invoke(GetUsersWithAddressesQuery $query): array
    {
        // -- Validate
        $requestingUser = $this->userCollection->getOne($query->userId);
        $requestingUser->mustBeManager();
        $requestingUser->mustHavePermissionToReadUsers();

        // -- Read
        return $this->getUsersWithAddresses();
    }

    /** @return array<int, ReadModel\UserWithAddresses> **/
    private function getUsersWithAddresses(): array
    {
         return $this->getUsersWithAddressesDataProvider->getUsersWithAddresses();
    }
}

GetUsersWithAddressesQueryHandler.php

<?php

declare(strict_types=1);

namespace App\Domain\UserManagement\ReadSide\GetUsersWithAddresses\ReadModel;

final readonly class UserWithAddresses
{
    public function __construct(
        public UserId $userId,
        public Name $name,
        public int $amountOfAddresses,
        /** @var array<int, Address> $addresses **/
        public array $addresses,
    ) {
    }
}

ReadModel\UserWithAddresses.php

<?php

declare(strict_types=1);

namespace App\Domain\UserManagement\ReadSide\GetUsersWithAddresses\ReadModel;

final readonly class Address
{
    public function __construct(
        public string $street,
        public string $postal,
        public Country $country,
    ) {
    }
}

ReadModel\Address.php

<?php

declare(strict_types=1);

namespace App\Domain\UserManagement\ReadSide\GetUsersWithAddresses;

final readonly class GetUsersWithAddressesDataProvider
{
    public function __construct(
        private DecoderConnectionWrapper $decoderConnectionWrapper,
        private Denormalizer $denormalizer,
    ) {
    }
    
    /** @return array<int, ReadModel\UserWithAddresses> */
    public function getUsersWithAddresses(): array
    {
        $result = $this->decoderConnectionWrapper->fetchAllAssociative(
            query: "
                SELECT
                    user.user_id,
                    user.name,
                    (
                        SELECT COUNT(*)
                        FROM address
                        WHERE address.user_id = user.user_id
                    ) AS amount_of_addresses,
                    (
                    SELECT json_agg(json_build_object(
                        'street', sorted_address.street,
                        'postal', sorted_address.postal,
                        'country', sorted_address.country
                    )) AS addresses
                    FROM (
                        SELECT
                            address.street,
                            address.postal,
                            address.country
                        FROM address
                        ORDER BY 
                          address.country ASC,
                          address.postal ASC,
                          address.street ASC
                    ) AS sorted_address
                )
            ",
            decoderOptions: [
                'amount_of_addresses' => DecoderOption::INT,
                'addresses' => DecoderOption::JSON_WITH_EMPTY_ARRAY_ON_NULL,
            ],
        );

        return $this->denormalizer->denormalizeArray(
            $result,
            ReadModel\UserWithAddresses::class,
        );
    }
}

GetUsersWithAddressesDataProvider.php

The read models would be the same without this concept. Instead of the data provider that constructs the read models, the query handler would trigger multiple queries through doctrine to pull all entities and run counts and loop through addresses instead. SQL is just better suited to such tasks and as a side effect is way more performant.