Pare de se repetir: use o padrão construtor para DTOs no Doctrine

Como você busca os dados do banco quando você não está usando entidades do Doctrine? Você provavelmente usa arrays comuns ou se você se importa com a tipagem, provavelmente usa DTOs. Vamos fingir que os dados que você precisa são tão complexos que você decide criar uma view no banco de dados só para o seu caso. Enquanto views de banco de dados podem ser tratadas como entidades somente-leitura, elas não possuem a flexibilidade de uma consulta feita na unha usando DQL ou o QueryBuilder.

Se você se encontra repetindo a mesma consulta várias e várias vezes, isso é um sinal de que está na hora de implementar o Padrão Construtor para evitar duplicatas. Essa abordagem vai centralizar toda a lógica relacionada a consulta a fim de torná-la testável com o PHPUnit e deixar o seu código com aspecto mais profissional.

Vamos começar com duas consultas simples que retornam o mesmo DTO do banco de dados.

<?php
/**
 * @return InvoiceListing[]
 */
function getInvoiceListingCollection(
    int $page,
    int $itemsPerPage
): array {
    return $this->createQueryBuilder('invoice')
        ->select(
            sprintf(
                <<<'DQL'
                    new %s(
                        invoice.id,
                        invoice.number,
                        invoice.emissionDate,
                        invoice.paidValue,
                        JSON_AGG(
                            JSON_BUILD_OBJECT(
                                'id', item.id,
                                'description', item.description,
                                'cents', item.cents
                            )
                        )
                    )
                DQL
                ,
                InvoiceListing::class
            )
        )
        ->leftJoin('invoice.invoiceItems', 'item')
        ->groupBy(
            'invoice.id',
            'invoice.number',
            'invoice.emissionDate',
            'invoice.paidValue'
        )
        ->setFirstResult(($page - 1) * $itemsPerPage)
        ->setMaxResults($itemsPerPage)
        ->getQuery()
        ->getArrayResult();
}

function findOneInvoiceListingById(int $id): InvoiceListing
{
    return $this->createQueryBuilder('invoice')
        ->select(
            sprintf(
                <<<'DQL'
                    new %s(
                        invoice.id,
                        invoice.number,
                        invoice.emissionDate,
                        invoice.paidValue,
                        JSON_AGG(
                            JSON_BUILD_OBJECT(
                                'id', item.id,
                                'description', item.description,
                                'cents', item.cents
                            )
                        )
                    )
                DQL
                ,
                InvoiceListing::class
            )
        )
        ->leftJoin('invoice.invoiceItems', 'item')
        ->groupBy(
            'invoice.id',
            'invoice.number',
            'invoice.emissionDate',
            'invoice.paidValue'
        )
        ->where('invoice.id = :invoiceId')
        ->setParameter(':invoiceId', $id)
        ->getQuery()
        ->getSingleResult();
}

Como você pode ver, existe um monte de lógica repetida nessa classe de repository do Doctrine. A classe pode crescer muito rápido se o mesmo DTO for consultado de formas diferentes. Mas, o que acontece se você mudar a estrutura do DTO? Você vai atualizar todas as consultas do repositório? Você está disposto a copiar e colar consultas antigas toda vez que precisa de uma nova com parâmetros diferentes?

Quando eu estava começando como programador PHP, eu faria desta forma.

<?php
private function getInvoiceListingQueryBuilder(): QueryBuilder
{
    return $this->createQueryBuilder('invoice')
        ->select(
            sprintf(
                <<<'DQL'
                    new %s(
                        invoice.id,
                        invoice.number,
                        invoice.emissionDate,
                        invoice.paidValue,
                        JSON_AGG(
                            JSON_BUILD_OBJECT(
                                'id', item.id,
                                'description', item.description,
                                'cents', item.cents
                            )
                        )
                    )
                DQL
                ,
                InvoiceListing::class
            )
        )
        ->leftJoin('invoice.invoiceItems', 'item')
        ->groupBy(
            'invoice.id',
            'invoice.number',
            'invoice.emissionDate',
            'invoice.paidValue'
        );
}

/**
 * @return InvoiceListing[]
 */
function getInvoiceListingCollection(
    int $page,
    int $itemsPerPage
): array {
    return $this->getInvoiceListingQueryBuilder()
        ->setFirstResult(($page - 1) * $itemsPerPage)
        ->setMaxResults($itemsPerPage)
        ->getQuery()
        ->getArrayResult();
}

function findOneInvoiceListingById(int $id): InvoiceListing
{
    return $this->getInvoiceListingQueryBuilder()
        ->where('invoice.id = :invoiceId')
        ->setParameter(':invoiceId', $id)
        ->getQuery()
        ->getSingleResult();
}

Isso pode parecer uma solução boa no início. No entanto, em projetos de grande porte, os repositórios do Doctrine podem ficar bastante bagunçados, principalmente quando os desenvolvedores tentam remover duplicatas de código por meio de métodos privados. Isso culmina numa classe inchada, cheia de métodos privados e com muitas responsabilidades, o que torna essa classe difícil de manter e testar.

Vamos construir uma classe simples que vai encapsular toda lógica por trás da consulta com DTO.

<?php

declare(strict_types=1);

namespace App\Builder\ApiResource\InvoiceListing;

use App\ApiResource\InvoiceListing\InvoiceListing;
use App\Entity\Invoice;
use Doctrine\ORM\QueryBuilder;
use function sprintf;

final readonly class InvoiceListingQueryBuilder
{
    private function __construct(private QueryBuilder $queryBuilder)
    {
        $this->queryBuilder
            ->select(
                sprintf(
                    <<<'DQL'
                        new %s(
                            invoice.id,
                            invoice.number,
                            invoice.emissionDate,
                            invoice.paidValue,
                            JSON_AGG(
                                JSON_BUILD_OBJECT(
                                    'id', item.id,
                                    'description', item.description,
                                    'cents', item.cents
                                )
                            )
                        )
                    DQL,
                    InvoiceListing::class
                )
            )
            ->from(Invoice::class, 'invoice')
            ->leftJoin('invoice.invoiceItems', 'item')
            ->groupBy(
                'invoice.id',
                'invoice.number',
                'invoice.emissionDate',
                'invoice.paidValue'
            );
    }

    public static function new(QueryBuilder $queryBuilder) {
        return new InvoiceListingQueryBuilder($qb);
    }
}

Essa classe vai seguir o Padrão Construtor conforme descrito no Refactoring Guru. Ela pode ser estendida com métodos que modificam os parâmetros da consulta sem duplicar uma linha de código.

Você consegue diferenciar entre as consultas no primeiro exemplo? Elas usam a mesma seleção, mas aplicam diferentes filtros no banco de dados. Essas diferenças podem ser implementadas como métodos simples na classe que constrói essa consulta, como se você estivesse escrevendo a sua própria versão do QueryBuilder.

Agora você deve estar se perguntando:

O Query Builder do Doctrine já segue o padrão construtor. Por que eu devo criar outro construtor em cima dele?

A resposta é simples: o QueryBuilder do Doctrine é genérico demais para o seu caso específico.

<?php

declare(strict_types=1);

namespace App\Builder\ApiResource\InvoiceListing;

use App\ApiResource\InvoiceListing\InvoiceListing;
use App\Entity\Invoice;
use Doctrine\ORM\QueryBuilder;
use function sprintf;

final readonly class InvoiceListingQueryBuilder
{
    private function __construct(private QueryBuilder $queryBuilder)
    {
        // ...
    }

    // ...

    public function withInvoiceId(int $id): static
    {
        $this->queryBuilder
            ->where('invoice.id = :invoiceId')
            ->setParameter(':invoiceId', $id);
        return $this;
    }

    public function withPagination(int $page, int $itemsPerPage): static
    {
        $this->queryBuilder
            ->setMaxResults($itemsPerPage)
            ->setFirstResult(($page - 1) * $itemsPerPage);
        return $this;
    }

    /**
     * @return InvoiceListing[]
     */
    public function getArrayResult(): array
    {
        return $this->queryBuilder->getQuery()->getArrayResult();
    }

    public function getSingleResult(): InvoiceListing
    {
        return $this->queryBuilder->getQuery()->getSingleResult();
    }
}

Essa é a classe completa com todos os métodos restantes, agora vamos ver como isso se parece no repositório do Doctrine.

<?php

function getInvoiceListingCollection(int $page, int $itemsPerPage): array
{
    return InvoiceListingQueryBuilder::new(
            $this->getEntityManager()->createQueryBuilder()
        )
        ->withPagination($page, $itemsPerPage)
        ->getArrayResult();
}

function findOneInvoiceListingById(int $id): InvoiceListing
{
    return InvoiceListingQueryBuilder::new(
            $this->getEntityManager()->createQueryBuilder()
        )
        ->withInvoiceId($id)
        ->getSingleResult();
}

Isso deixa o código muito mais limpo, não é mesmo? Agora vamos ver como um teste unitário se parece:

<?php

declare(strict_types=1);

namespace Tests\Unit\Builder\ApiResource\InvoiceListing;

use App\Builder\ApiResource\InvoiceListing\InvoiceListingQueryBuilder;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\TestCase;

final class InvoiceListingQueryBuilderTest extends TestCase
{
    public function testWithPaginationSetsCorrectFirstResult(): void
    {
        $queryBuilder = $this->createMock(QueryBuilder::class);
        $queryBuilder->method('select')->willReturnSelf();
        $queryBuilder->method('from')->willReturnSelf();
        $queryBuilder->method('leftJoin')->willReturnSelf();
        $queryBuilder->method('groupBy')->willReturnSelf();
        $queryBuilder->method('setMaxResults')->willReturnSelf();

        $queryBuilder->expects($this->once())
            ->method('setFirstResult')
            ->with(20)
            ->willReturnSelf();

        $builder = InvoiceListingQueryBuilder::new($queryBuilder);
        $builder->withPagination(3, 10);
    }
}

Esses tipos de testes podem ser gerados por qualquer ferramenta de IA 1 razoável.

Conclusão

Usar DTOs no Doctrine não significa que as suas classes de repositório precisam se tornar repetitivas e inchadas. Ao introduzir uma pequena abstração com o Padrão Construtor você pode isolar a lógica da consulta, reduzir código duplicado e escrever código limpo e manutenível.

Essa abordagem também pode deixar as suas consultas mais fáceis de testar e reutilizar em diferentes contextos, como paginação, filtros e endpoints customizados, raramente repetindo a mesma lógica duas vezes.

Portanto, da próxima vez que você repetir um createQueryBuilder(), dê um passo atrás e considere: talvez esteja na hora de construir um construtor.


  1. Como ChatGPT, Claude AI, etc. ↩︎