Symfony: como aplicar o padrão strategy

Quando uma aplicação precisa se comportar de maneiras diferentes os programadores têm o costume de colocar várias condicionais e um retorno para cada uma delas.

<?php
function getPercentagePaid(): int
{
    $totalAmountPaid = $this->invoiceListing->paidValue;
    if ($totalAmountPaid === 0) {
        return 0;
    }

    $invoiceTotal = $this->invoiceListing->getTotalAmount();

    if ($invoiceTotal === 0) {
        return 0;
    }

    $proportionalPaid = ((int) $this->cents / $invoiceTotal) * $totalAmountPaid;

    $result = min($proportionalPaid / (int) $this->cents, 1) * 100;
    return (int) $result;
}

Essa forma de programar é problemática porque dificulta a criação de testes e a complexidade da função aumenta conforme novas regras são criadas. Essa prática pode ser encontrada em diversos cenários e também pode ser feito com o uso do switch ou o match do PHP 8.

<?php
function getStatus(): InvoiceStatus
{
    $invoiceItemsTotalPay = $this->items->reduce(
        fn (int $initial, InvoiceListingItem $a) =>
            (int) $a->cents + $initial,
        0
    );
    return match (true) {
        $this->paidValue === 0 => InvoiceStatus::PENDENTE,
        $invoiceItemsTotalPay !== 0
            && (int) $this->paidValue >= $invoiceItemsTotalPay => InvoiceStatus::PAGO,
        default => InvoiceStatus::PARCIALMENTE_PAGO,
    };
}

O padrão Strategy veio para resolver esse problema, com ele você pode colocar inúmeras condições sem aumentar a complexidade do código e testar cada cenário individualmente. O melhor de tudo é que o código fica elegante e mais profissional na hora da entrega.

Para aqueles que não sabem, o Strategy pattern é um padrão de código onde o sistema pode fazer a mesma coisa de várias maneiras diferentes, você pode criar inúmeras estratégias sem aumentar a complexidade do código e o melhor de tudo é que cada estratégia pode ser testada individualmente com o PHPUnit sem a necessidade de mock de dependências.

Fluxo de execução do padrão
strategy

Portanto, se o sistema se comporta de várias maneiras e possui o mesmo tipo de retorno, considere usar o padrão de estratégias ao invés de várias condicionais. No Symfony 7 você consegue criar um código que segue esse padrão sem esforço nenhum e com mínima configuração possível.

Como aplicar

O padrão é composto por:

  1. Uma classe de contexto
  2. Uma classe por estratégia
  3. Classes auxiliares

A classe de contexto vai varrer todas as estratégias até encontrar alguma que bate com o cenário atual e depois retornar o resultado da estratégia. A estratégia implementa a condicional e o retorno em si (substituindo as declarações if e os múltiplos retornos), e as classes auxiliares são classes que representam estruturas de dados comuns que podem ser utilizados no contexto atual.

Neste artigo, você vai encontrar três exemplos reais.

  1. Regra de negócio
  2. Operações da API Platform
  3. Busca inteligente no Doctrine

Exemplo 1: Regra de negócio

Seguindo o primeiro exemplo do início do artigo, nós vamos mapear todos os dados que vão ser utilizados no contexto com a seguinte classe.

<?php

namespace App\Service\Invoice\PercentageCalculation;

readonly class PercentageCalcRequest
{
    public function __construct(
        public int $paidValue,
        public int $totalAmount,
        public int $invoiceItemCents,
    ) {}
}

Agora vamos criar uma interface para dizer como a estratégia deve se comportar, a função matches() substitui o if e a função handle() substitui o retorno.

<?php

namespace App\Service\Invoice\PercentageCalculation;

use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.invoice_item_percentage_calc')]
interface PercentageCalcInterface
{
    public function matches(PercentageCalcRequest $req): bool;

    public function handle(PercentageCalcRequest $req): int;
}

Perceba que coloquei um atributo do Symfony em cima da interface, esse atributo vai fazer com que o injetor de dependências classifique todos os serviços que implementam essa estratégia com o texto app.invoice_item_percentage_calc. Esse texto será referenciado mais tarde na classe de contexto.

<?php

namespace App\Service\Invoice\PercentageCalculation;

use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;

readonly class PercentageCalculator
{
    /**
     * @param iterable<int,PercentageCalcInterface> $strategies
     */
    public function __construct(
        #[AutowireIterator('app.invoice_item_percentage_calc')]
        private iterable $strategies
    ) {}

    public function handle(PercentageCalcRequest $req): int
    {
        foreach ($this->strategies as $strategy) {
            if ($strategy->matches($req)) {
                return $strategy->handle($req);
            }
        };
        // Fallback value
        return 0;
    }
}

Por fim, a implementação da classe de contexto. Lembrando que você não precisa botar o nome Contexto na classe. O código cliente1 não precisa saber que você está implementando o Strategy pattern (veja o exemplo 2).

A classe de contexto recebe no construtor todas as estratégias graças ao atributo AutowireIterator. O contexto, quando chamado, percorre todas as estratégias e retorna o resultado da primeira que retornar verdadeiro. Quando nenhuma estratégia bate, um valor padrão é retornado. Você pode lançar uma exceção ou retornar um valor a mão.

Agora que temos as classes básicas criadas, podemos prosseguir com a criação das estratégias para calcular o valor da porcentagem.

<?php

namespace App\Service\Invoice\PercentageCalculation\Strategies;

use App\Service\Invoice\PercentageCalculation\PercentageCalcInterface;
use App\Service\Invoice\PercentageCalculation\PercentageCalcRequest;

class AmountPaidIsZero implements PercentageCalcInterface
{
    public function matches(PercentageCalcRequest $req): bool
    {
        return $req->paidValue === 0;
    }

    public function handle(PercentageCalcRequest $_): int
    {
        return 0;
    }
}
<?php

namespace App\Service\Invoice\PercentageCalculation\Strategies;

use App\Service\Invoice\PercentageCalculation\PercentageCalcInterface;
use App\Service\Invoice\PercentageCalculation\PercentageCalcRequest;

class TotalAmountIsZero implements PercentageCalcInterface
{
    public function matches(PercentageCalcRequest $req): bool
    {
        return $req->totalAmount === 0;
    }

    public function handle(PercentageCalcRequest $req): int
    {
        return 0;
    }
}
<?php

namespace App\Service\Invoice\PercentageCalculation\Strategies;

use App\Service\Invoice\PercentageCalculation\PercentageCalcInterface;
use App\Service\Invoice\PercentageCalculation\PercentageCalcRequest;

class ProportionalPayment implements PercentageCalcInterface
{
    public function matches(PercentageCalcRequest $req): bool
    {
        return
            $req->invoiceItemCents > 0
            && $req->paidValue > 0
            && $req->totalAmount > 0;
    }

    public function handle(PercentageCalcRequest $req): int
    {
        $proportionalPaid = ($req->invoiceItemCents / $req->totalAmount) * $req->paidValue;

        return (int) (min($proportionalPaid / $req->invoiceItemCents, 1) * 100);
    }
}

Pronto! Todas as estratégias estão prontas e agora serão injetadas automaticamente pelo injetor de dependências, nenhuma configuração a mais é necessária. Quando o código cliente chamar a classe de contexto, você vai obter o resultado conforme a estratégia interna selecionada.

Exemplo de código cliente:

<?php
public function getPercentagePaid(): int
{
    return $this->percentageCalculator->handle(
        new PercentageCalcRequest(
            $this->invoiceListing->paidValue,
            $this->invoiceListing->getTotalAmount(),
            (int) $this->cents,
        )
    );
}

Exemplo de teste unitário:

<?php

namespace Tests\Unit\Service\Invoice\PercentageCalculation\Strategies;

use App\Service\Invoice\PercentageCalculation\PercentageCalcRequest;
use App\Service\Invoice\PercentageCalculation\Strategies\ProportionalPayment;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;

class ProportionalPaymentTest extends TestCase
{
    #[TestWith([1000, 500, 2000, true])]
    #[TestWith([1, 1, 1, true])]
    #[TestWith([100, 50, 200, true])]
    #[TestWith([0, 500, 2000, false])]
    #[TestWith([-100, 500, 2000, false])]
    #[TestWith([1000, 0, 2000, false])]
    #[TestWith([1000, -500, 2000, false])]
    #[TestWith([1000, 500, 0, false])]
    #[TestWith([1000, 500, -2000, false])]
    #[TestWith([0, 0, 0, false])]
    #[TestWith([-100, -500, -2000, false])]
    public function testMatches(int $invoiceItemCents, int $paidValue, int $totalAmount, bool $expected): void
    {
        $strategy = new ProportionalPayment();
        $request = new PercentageCalcRequest(
            $paidValue,
            $totalAmount,
            $invoiceItemCents
        );

        $result = $strategy->matches($request);

        $this->assertEquals($expected, $result);
    }
}

Exemplo 2: Operações da API Platform

Se você usa API-Platform você pode utilizar o padrão Strategy para implementar um provedor de estado compatível com várias operações.

Por exemplo, imagine que você vai implementar um paginador e uma função para carregar um determinado DTO. Ao invés de criar um provedor para cada operação, você pode criar um provedor para todas as operações envolvendo esse mesmo DTO. O objetivo é centralizar todas as operações em um único provedor e transformar cada operação em uma estratégia seguindo o Strategy pattern.

Comece criando a classe de contexto:

<?php

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Operation\InvoiceListing\InvoiceListingOperationInterface;
use App\Operation\InvoiceListing\InvoiceListingOperationParams;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
use App\ApiResource\InvoiceListing\InvoiceListing;

/**
 * @implements ProviderInterface<InvoiceListing>
 */
final readonly class InvoiceListingProvider implements ProviderInterface
{
    /**
     * @param iterable<int,InvoiceListingOperationInterface> $operations
     */
    public function __construct(
        #[AutowireIterator('app.invoice_listing_operation')]
        private iterable $operations
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $context['filters']['page'] ??= 1;
        $context['filters']['itemsPerPage'] ??= 0;
        $uriVariables['id'] ??= 0;
        foreach ($this->operations as $op) {
            if ($op->matches($operation)) {
                return $op->handle(
                    new InvoiceListingOperationParams(
                        (int) $context['filters']['page'],
                        (int) $context['filters']['itemsPerPage'],
                        (int) $uriVariables['id']
                    )
                );
            }
        }
        return null;
    }
}

Em seguida uma classe que vai guardar as informações necessárias para as operações Get e GetCollection.

<?php

namespace App\Operation\InvoiceListing;

readonly class InvoiceListingOperationParams
{
    public function __construct(
        public int $page,
        public int $itemsPerPage,
        public int $id,
    ) {}
}

E por fim a interface e a implementação de uma das estratégias:

<?php

namespace App\Operation\InvoiceListing;

use ApiPlatform\State\Pagination\PaginatorInterface;
use App\ApiResource\InvoiceListing\InvoiceListing;
use App\ApiResource\InvoiceListing\InvoiceListingCollection;
use ApiPlatform\Metadata\Operation;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;

#[AutoconfigureTag('app.invoice_listing_operation')]
interface InvoiceListingOperationInterface
{
    public function matches(Operation $operation): bool;

    /**
     * @return PaginatorInterface<InvoiceListing>
     */
    public function handle(InvoiceListingOperationParams $params): null|InvoiceListingCollection|InvoiceListing|PaginatorInterface;
}
<?php

declare(strict_types=1);

namespace App\Operation\InvoiceListing\Impl;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\State\Pagination\PaginatorInterface;
use App\ApiResource\InvoiceListing\InvoiceListing;
use App\ApiResource\InvoiceListing\InvoiceListingCollectionPaginated;
use App\Operation\InvoiceListing\InvoiceListingOperationInterface;
use App\ApiResource\InvoiceListing\InvoiceListingCollection;
use App\Operation\InvoiceListing\InvoiceListingOperationParams;
use App\Repository\InvoiceRepository;

readonly class GetCollectionImpl implements InvoiceListingOperationInterface
{
    public function __construct(
        private InvoiceRepository $invoiceRepository
    ) {}

    public function matches(Operation $operation): bool
    {
        return $operation instanceof GetCollection;
    }

    public function handle(InvoiceListingOperationParams $params): null|PaginatorInterface|InvoiceListingCollection|InvoiceListing
    {
        return new InvoiceListingCollectionPaginated(
            $this->invoiceRepository->getInvoiceListingCollection(
                $params->page,
                $params->itemsPerPage
            ),
            $params->page,
            $params->itemsPerPage,
            $this->invoiceRepository->count()
        );
    }
}

Perceba, ao invés de nós criarmos uma condicional para cada operação, nós simplesmente criamos um grupo de estratégias onde cada estratégia corresponde a uma operação do API Platform.

Exemplo 3: Doctrine e busca inteligente

Vamos supor que você está criando a lógica por trás da pesquisa que um usuário está fazendo na sua aplicação, essa busca pode contemplar diversos campos da sua base de dados e se você não segue o padrão de código Strategy, você pode muito bem acabar fazendo uma consulta deste tipo:

<?php
/** @var \Doctrine\ORM\QueryBuilder $qb */
$qb = $this->createQueryBuilder('person');
$qb
    ->where('person.name like :searchTerm')
    ->orWhere('person.email = :searchTerm')
    ->orWhere('person.cpf = :searchTerm')
    ->setParameter(':searchTerm', $searchTerm);

A fim de deixar a sua busca mais rápida e inteligente, você pode criar estratégias para identificar o que o usuário está buscando e aplicar o filtro correspondente.

<?php

namespace App\Operation\PersonSearch\Impl;

use App\Operation\PersonSearch\SearchStrategyInterface;
use Doctrine\ORM\QueryBuilder;
use function filter_var;

class SearchByEmail implements SearchStrategyInterface
{
    public function matches(string $searchTerm): bool
    {
        return false !== filter_var($searchTerm, FILTER_VALIDATE_EMAIL);
    }

    public function apply(QueryBuilder $builder, string $searchTerm): void
    {
        $builder
            ->orWhere('person.email = :searchTerm')
            ->setParameter(':searchTerm', $searchTerm);
    }
}
<?php

namespace App\Operation\PersonSearch\Impl;

use App\Operation\PersonSearch\SearchStrategyInterface;
use Doctrine\ORM\QueryBuilder;
use function preg_match;

class SearchByUnmaskedCpf implements SearchStrategyInterface
{
    public function matches(string $searchTerm): bool
    {
        return preg_match('/^\d{11}$/', $searchTerm) === 1;
    }

    public function apply(QueryBuilder $builder, string $searchTerm): void
    {
        $builder
            ->orWhere('person.cpf = :searchTerm')
            ->setParameter(':searchTerm', $searchTerm);
    }
}
<?php

namespace App\Operation\PersonSearch\Impl;

use App\Operation\PersonSearch\SearchStrategyInterface;
use Doctrine\ORM\QueryBuilder;
use function preg_match;
use function Utils\Cpf\unmaskCpf;

class SearchByMaskedCpf implements SearchStrategyInterface
{
    public function matches(string $searchTerm): bool
    {
        return preg_match('/^\d{3}\.\d{3}\.\d{3}-\d{2}$/', $searchTerm) === 1;
    }

    public function apply(QueryBuilder $builder, string $searchTerm): void
    {
        $builder
            ->orWhere('person.cpf = :searchTerm')
            ->setParameter(':searchTerm', unmaskCpf($searchTerm));
    }
}
<?php

namespace App\Operation\PersonSearch\Impl;

use App\Operation\PersonSearch\SearchStrategyInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(priority: -1)]
class SearchByName implements SearchStrategyInterface
{
    public function matches(string $searchTerm): bool
    {
        return true;
    }

    public function apply(QueryBuilder $builder, string $searchTerm): void
    {
        $builder
            ->orWhere('person.name like :searchTerm')
            ->setParameter(':searchTerm', $searchTerm);
    }
}

Como já trabalhei em bancos de dados gigantescos, esse tipo de otimização se torna necessária. Você também pode escolher a ordem na qual as estratégias são colocadas usando atributos da injeção de dependência do Symfony (AsTaggedItem). Ele diz ao DI2 que a estratégia deve ser a última da lista e isso ajuda a evitar conflitos entre uma estratégia e outra.

Considerações finais

Agora você sabe como implementar o padrão Strategy no Symfony 7. Nem sempre a classe de contexto precisa retornar algo — às vezes, você pode apenas criar um conjunto de validações que não retornam nada e jogam exceções.

É possível combinar o Strategy com outros padrões, como o Builder, e criar testes muito mais simples e isolados.

Sei que, à primeira vista, o padrão pode parecer uma complicação para algo simples. Mas acredite: em projetos de grande porte, quanto mais você separar responsabilidades, mais fácil será manter e evoluir seu código.


  1. “Código cliente” é o código responsável por consumir a implementação que você está criando. Por mais que todo código fique no mesmo projeto, existem códigos que servem como base para outros códigos consumirem. ↩︎

  2. Dependency injection ou injetor de dependências. ↩︎