← Voltar ao blog

Service contracts e repositories no Magento 2 explicados

Rodrigo Biazzi

Service contracts são a parte do Magento 2 que a maioria dos devs usa todo dia sem pensar no porquê de existirem. Você injeta o ProductRepositoryInterface, chama o get() e segue em frente. Funciona até você precisar que a sua própria entidade seja utilizável do mesmo jeito por REST, resolvers GraphQL, outros módulos e testes. Aí você tem que escrever o contrato, e os detalhes começam a importar.

Este artigo mostra o que é de fato um service contract no Magento Open Source e no Adobe Commerce 2.4.8, como repositories e SearchCriteria se encaixam, como montar um repository para uma entidade customizada e os casos em que uma collection simples é a ferramenta melhor.

O que é um service contract

Um service contract é um conjunto de interfaces PHP na pasta Api de um módulo. Existem dois tipos:

  • Data interfaces em Api/Data. Descrevem uma entidade por meio de getters e setters, por exemplo Magento\Catalog\Api\Data\ProductInterface.
  • Service interfaces em Api. Descrevem operações, por exemplo Magento\Catalog\Api\ProductRepositoryInterface ou Magento\Sales\Api\OrderManagementInterface.

O ponto é a estabilidade. Um módulo promete que essas interfaces continuam funcionando entre versões minor, enquanto os models, resource models e collections por trás podem mudar. Outros módulos dependem da interface, e as preferences no di.xml decidem qual classe realmente roda. O framework de Web API também lê essas interfaces por reflection para montar os endpoints REST, então o mesmo contrato atende código PHP e clientes HTTP.

Repositories na prática

Um repository é uma service interface focada em persistência. Os métodos usuais são save(), getById() (ou get() para produtos, que carrega por SKU), getList(), delete() e deleteById(). Carregar um produto por SKU fica assim:

<?php
declare(strict_types=1);

namespace Acme\Catalog\Model;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Exception\NoSuchEntityException;

class ProductLookup
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository
    ) {
    }

    public function findBySku(string $sku, int $storeId): ?ProductInterface
    {
        try {
            return $this->productRepository->get($sku, false, $storeId);
        } catch (NoSuchEntityException) {
            return null;
        }
    }
}

Duas coisas para saber sobre o repository de produtos em particular. Primeiro, ele guarda os produtos carregados num cache em memória indexado por SKU, store e modo de edição. Num comando CLI longo ou num consumer que processa milhares de produtos, esse cache cresce. O quarto argumento do get(), $forceReload, o ignora, e o repository também tem um método cleanCache() na classe concreta. Segundo, salvar um produto pelo repository salva o produto inteiro. Se você carregou na store 0 e salva, grava valores no escopo default. Se carregou numa store view, os valores podem acabar gravados no nível da store. Para atualizações em massa de um único atributo, o Magento\Catalog\Model\ResourceModel\Product\Action::updateAttributes() costuma ser mais rápido e seguro.

getList e SearchCriteria

O getList() recebe um SearchCriteriaInterface e retorna um objeto de resultados de busca com os itens e a contagem total. Você monta o criteria com o SearchCriteriaBuilder:

<?php
declare(strict_types=1);

namespace Acme\Sales\Model;

use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SortOrderBuilder;
use Magento\Sales\Api\OrderRepositoryInterface;

class PendingOrders
{
    public function __construct(
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
        private readonly SortOrderBuilder $sortOrderBuilder
    ) {
    }

    /**
     * @return \Magento\Sales\Api\Data\OrderInterface[]
     */
    public function fetch(string $from, int $pageSize = 100): array
    {
        $sortOrder = $this->sortOrderBuilder
            ->setField('created_at')
            ->setAscendingDirection()
            ->create();

        $criteria = $this->searchCriteriaBuilder
            ->addFilter('status', 'pending')
            ->addFilter('created_at', $from, 'gteq')
            ->addSortOrder($sortOrder)
            ->setPageSize($pageSize)
            ->setCurrentPage(1)
            ->create();

        return $this->orderRepository->getList($criteria)->getItems();
    }
}

Cada chamada de addFilter() cria o seu próprio filter group, e os filter groups são combinados com AND. Filtros dentro do mesmo grupo são combinados com OR. Para obter uma condição OR você monta os filtros com o FilterBuilder, passa para o FilterGroupBuilder e define os grupos no criteria.

O builder tem estado. Depois do create() ele é resetado, mas se você injeta o mesmo builder numa classe e monta dois criteria em paralelo, vai misturá-los. Monte um objeto criteria por vez.

Sempre defina um page size. Um getList() sem ele carrega todas as linhas que casam, e numa tabela de pedidos ou produtos isso é um problema de memória esperando para acontecer.

Criando um repository para a sua própria entidade

Suponha que um módulo tenha uma tabela acme_notice, um model, um resource model e uma collection, como descrito no guia de módulo customizado. Para dar a ela um service contract você precisa de quatro interfaces e algumas preferences.

As interfaces

<?php
declare(strict_types=1);

namespace Acme\Notice\Api\Data;

interface NoticeInterface
{
    public const NOTICE_ID = 'notice_id';
    public const MESSAGE = 'message';
    public const IS_ACTIVE = 'is_active';

    /**
     * @return int|null
     */
    public function getNoticeId(): ?int;

    /**
     * @return string
     */
    public function getMessage(): string;

    /**
     * @param string $message
     * @return $this
     */
    public function setMessage(string $message): self;

    /**
     * @return bool
     */
    public function getIsActive(): bool;

    /**
     * @param bool $isActive
     * @return $this
     */
    public function setIsActive(bool $isActive): self;
}
<?php
declare(strict_types=1);

namespace Acme\Notice\Api;

use Acme\Notice\Api\Data\NoticeInterface;
use Acme\Notice\Api\Data\NoticeSearchResultsInterface;
use Magento\Framework\Api\SearchCriteriaInterface;

interface NoticeRepositoryInterface
{
    /**
     * @param \Acme\Notice\Api\Data\NoticeInterface $notice
     * @return \Acme\Notice\Api\Data\NoticeInterface
     * @throws \Magento\Framework\Exception\CouldNotSaveException
     */
    public function save(NoticeInterface $notice): NoticeInterface;

    /**
     * @param int $noticeId
     * @return \Acme\Notice\Api\Data\NoticeInterface
     * @throws \Magento\Framework\Exception\NoSuchEntityException
     */
    public function getById(int $noticeId): NoticeInterface;

    /**
     * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria
     * @return \Acme\Notice\Api\Data\NoticeSearchResultsInterface
     */
    public function getList(SearchCriteriaInterface $searchCriteria): NoticeSearchResultsInterface;

    /**
     * @param int $noticeId
     * @return bool
     * @throws \Magento\Framework\Exception\CouldNotDeleteException
     */
    public function deleteById(int $noticeId): bool;
}

Os docblocks não são enfeite. O framework de Web API lê as anotações @param e @return com nomes de classe totalmente qualificados para serializar e desserializar os dados. Os return types nativos sozinhos não bastam se você pretende expor o repository por REST.

A interface de resultados de busca só restringe o tipo do item:

<?php
declare(strict_types=1);

namespace Acme\Notice\Api\Data;

use Magento\Framework\Api\SearchResultsInterface;

interface NoticeSearchResultsInterface extends SearchResultsInterface
{
    /**
     * @return \Acme\Notice\Api\Data\NoticeInterface[]
     */
    public function getItems();

    /**
     * @param \Acme\Notice\Api\Data\NoticeInterface[] $items
     * @return $this
     */
    public function setItems(array $items);
}

A implementação

<?php
declare(strict_types=1);

namespace Acme\Notice\Model;

use Acme\Notice\Api\Data\NoticeInterface;
use Acme\Notice\Api\Data\NoticeSearchResultsInterface;
use Acme\Notice\Api\Data\NoticeSearchResultsInterfaceFactory;
use Acme\Notice\Api\NoticeRepositoryInterface;
use Acme\Notice\Model\ResourceModel\Notice as NoticeResource;
use Acme\Notice\Model\ResourceModel\Notice\CollectionFactory;
use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;

class NoticeRepository implements NoticeRepositoryInterface
{
    public function __construct(
        private readonly NoticeResource $resource,
        private readonly NoticeFactory $noticeFactory,
        private readonly CollectionFactory $collectionFactory,
        private readonly CollectionProcessorInterface $collectionProcessor,
        private readonly NoticeSearchResultsInterfaceFactory $searchResultsFactory
    ) {
    }

    public function save(NoticeInterface $notice): NoticeInterface
    {
        try {
            $this->resource->save($notice);
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save the notice: %1', $e->getMessage()), $e);
        }
        return $notice;
    }

    public function getById(int $noticeId): NoticeInterface
    {
        $notice = $this->noticeFactory->create();
        $this->resource->load($notice, $noticeId);
        if (!$notice->getNoticeId()) {
            throw new NoSuchEntityException(__('Notice with id "%1" does not exist.', $noticeId));
        }
        return $notice;
    }

    public function getList(SearchCriteriaInterface $searchCriteria): NoticeSearchResultsInterface
    {
        $collection = $this->collectionFactory->create();
        $this->collectionProcessor->process($searchCriteria, $collection);

        $results = $this->searchResultsFactory->create();
        $results->setSearchCriteria($searchCriteria);
        $results->setItems($collection->getItems());
        $results->setTotalCount($collection->getSize());
        return $results;
    }

    public function deleteById(int $noticeId): bool
    {
        try {
            $this->resource->delete($this->getById($noticeId));
        } catch (NoSuchEntityException $e) {
            throw $e;
        } catch (\Exception $e) {
            throw new CouldNotDeleteException(__('Could not delete the notice: %1', $e->getMessage()), $e);
        }
        return true;
    }
}

O CollectionProcessorInterface já tem uma preference global no app/etc/di.xml que aplica filtros, ordenação e paginação à collection. Você só precisa de um processor customizado se um campo de filtro não mapeia direto para uma coluna, por exemplo um filtro numa tabela com join.

Ligando tudo no di.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Acme\Notice\Api\Data\NoticeInterface" type="Acme\Notice\Model\Notice"/>
    <preference for="Acme\Notice\Api\NoticeRepositoryInterface" type="Acme\Notice\Model\NoticeRepository"/>
    <preference for="Acme\Notice\Api\Data\NoticeSearchResultsInterface" type="Magento\Framework\Api\SearchResults"/>
</config>

Apontar a interface de resultados de busca para Magento\Framework\Api\SearchResults é o mesmo atalho que vários módulos do core usam. A classe do model implementa NoticeInterface e estende AbstractModel, com getters que convertem os dados brutos para os tipos declarados. As classes factory são geradas, o que é tratado no artigo sobre factories e proxies.

Quando uma collection é a melhor escolha

Repositories são o padrão certo para código que outros módulos, APIs ou testes vão chamar. Nem sempre são a ferramenta certa dentro do seu próprio módulo.

  • Leituras em massa. Um repository hidrata data objects completos e, para produtos e pedidos, roda loaders e plugins extras. Se você precisa de duas colunas de 50.000 linhas, uma collection com addFieldToSelect() ou um select direto na conexão do resource é muito mais barato.
  • Grids do admin. As listagens de UI component usam os próprios data providers e collections. Forçá-las por um repository adiciona trabalho sem benefício.
  • Indexers e exportações. Costumam ler em lotes por faixa de ID com SQL puro, como mostrado no artigo sobre indexer customizado.

A regra que eu sigo é simples. O comportamento público passa por um contrato. O trabalho pesado interno pode usar o que for mais rápido, desde que fique dentro do módulo.

Erros comuns

  • Chamar $model->load() e $model->save() diretamente. Os dois métodos estão deprecated no AbstractModel. Use o resource model ou o repository.
  • Injetar a classe concreta do repository em vez da interface. Isso anula as preferences e faz plugins na interface perderem as suas chamadas.
  • Retornar arrays de dados misturados de um método de service. A Web API não consegue serializá-los de forma confiável. Retorne data interfaces ou tipos escalares com docblocks corretos.
  • Esquecer que plugins em repositories são comuns. Módulos de terceiros costumam adicionar plugins afterGet ou afterGetList para extension attributes, o que pode deixar uma chamada de repository muito mais lenta do que o esperado. Faça profiling antes de presumir.

FAQ

Preciso de um repository para cada tabela customizada?

Não. Se os dados só são usados dentro do seu módulo e nunca expostos por APIs, um resource model e uma collection bastam. Adicione um service contract quando outros módulos, REST ou GraphQL precisarem trabalhar com a entidade.

Por que meu endpoint REST não consegue serializar o meu data object?

Em geral os docblocks estão faltando ou usam nomes de classe curtos. A Web API lê as anotações @return e @param, e elas precisam de nomes de classe totalmente qualificados ou tipos escalares.

Como escrevo um filtro OR com o SearchCriteriaBuilder?

Crie os filtros com o FilterBuilder, coloque-os num único filter group com o FilterGroupBuilder e passe esse grupo para o setFilterGroups no builder. Filtros no mesmo grupo são combinados com OR.

O cache do repository de produtos é um problema?

Em requisições normais ele ajuda. Em comandos CLI longos e consumers de fila ele pode crescer sem limite, então use o argumento forceReload ou limpe entre os lotes.

Atendo como freelancer em Magento e Adobe Commerce. Se precisa de ajuda para desenhar service contracts ou organizar a arquitetura de um módulo, fale comigo.

Artigos relacionados