Service contracts e repositories no Magento 2 explicados
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 exemploMagento\Catalog\Api\Data\ProductInterface. - Service interfaces em
Api. Descrevem operações, por exemploMagento\Catalog\Api\ProductRepositoryInterfaceouMagento\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 noAbstractModel. 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
afterGetouafterGetListpara 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
PWA Studio vs headless customizado no Magento: visão de dev
PWA Studio vs um frontend headless customizado no Magento: arquitetura, custos reais, lacunas do GraphQL, SEO, checkout e quando um theme server rendered vence.
Extension attributes no Magento 2: dados em pedidos e APIs
Adicione dados customizados a entidades do Magento 2 com extension attributes: extension_attributes.xml, plugins de repository, storage, saída REST e armadilhas comuns.
Magento 2: tipos de cache, clean, flush e cache type próprio
O que cada cache type do Magento 2 guarda, a diferença real entre cache:clean e cache:flush, caches invalidados e como criar seu próprio cache type.
Shipping method customizado no Magento 2: crie um módulo de carrier
Como criar um shipping method customizado no Magento 2.4: config.xml, system.xml, um carrier model com collectRates, regras de frete grátis e APIs externas de tarifa.