Magento 2 Service Contracts and Repositories Explained
Service contracts are the part of Magento 2 that most developers use every day without thinking about why they exist. You inject ProductRepositoryInterface, call get(), and move on. That works until you need your own entity to be usable from REST, GraphQL resolvers, other modules and tests in the same way. Then you have to write the contract yourself, and the details start to matter.
This article covers what a service contract actually is in Magento Open Source and Adobe Commerce 2.4.8, how repositories and SearchCriteria fit together, how to build a repository for a custom entity, and the cases where a plain collection is the better tool.
What a service contract is
A service contract is a set of PHP interfaces in a module's Api folder. There are two kinds:
- Data interfaces in
Api/Data. They describe an entity through getters and setters, for exampleMagento\Catalog\Api\Data\ProductInterface. - Service interfaces in
Api. They describe operations, for exampleMagento\Catalog\Api\ProductRepositoryInterfaceorMagento\Sales\Api\OrderManagementInterface.
The point is stability. A module promises that these interfaces keep working across minor versions, while the models, resource models and collections behind them can change. Other modules depend on the interface, and di.xml preferences decide which class actually runs. The Web API framework also reads these interfaces through reflection to build REST endpoints, so the same contract serves PHP code and HTTP clients.
Repositories in practice
A repository is a service interface focused on persistence. The usual methods are save(), getById() (or get() for products, which loads by SKU), getList(), delete() and deleteById(). Loading a product by SKU looks like this:
<?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;
}
}
}
Two things to know about the product repository specifically. First, it keeps loaded products in an in memory cache keyed by SKU, store and edit mode. In a long running CLI command or a consumer that processes thousands of products, that cache grows. The fourth argument of get(), $forceReload, skips it, and the repository also has a cleanCache() method on the concrete class. Second, saving a product through the repository saves the whole product. If you loaded it in store 0 and save, you write default scope values. If you loaded it in a store view, values may end up stored at store level. For mass updates of a single attribute, Magento\Catalog\Model\ResourceModel\Product\Action::updateAttributes() is usually faster and safer.
getList and SearchCriteria
getList() takes a SearchCriteriaInterface and returns a search results object with items and a total count. You build the criteria with 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();
}
}
Each addFilter() call creates its own filter group, and filter groups are combined with AND. Filters inside the same group are combined with OR. To get an OR condition you build the filters with FilterBuilder and pass them to FilterGroupBuilder, then set the groups on the criteria.
The builder is stateful. After create() it resets, but if you inject the same builder into a class and build two criteria in parallel, you will mix them up. Build one criteria object at a time.
Always set a page size. A getList() without one loads every matching row, and on an order or product table that is a memory problem waiting to happen.
Building a repository for your own entity
Suppose a module has a acme_notice table, a model, a resource model and a collection, as described in the custom module guide. To give it a service contract you need four interfaces and a few preferences.
The 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;
}
The docblocks are not decoration. The Web API framework reads @param and @return annotations with fully qualified class names to serialize and deserialize data. Native return types alone are not enough if you plan to expose the repository over REST.
The search results interface only narrows the item type:
<?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);
}
The implementation
<?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;
}
}
CollectionProcessorInterface already has a global preference in app/etc/di.xml that applies filters, sort orders and pagination to the collection. You only need a custom processor if a filter field does not map directly to a column, for example a filter on a joined table.
Wiring it in 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>
Pointing the search results interface at Magento\Framework\Api\SearchResults is the same shortcut several core modules take. The model class implements NoticeInterface and extends AbstractModel, with getters that cast the raw data to the declared types. The factory classes are generated, which is covered in the article on factories and proxies.
When a collection is the better choice
Repositories are the right default for code that other modules, APIs or tests will call. They are not always the right tool inside your own module.
- Bulk reads. A repository hydrates full data objects and, for products and orders, runs extra loaders and plugins. If you need two columns from 50,000 rows, a collection with
addFieldToSelect()or a direct select on the resource connection is much cheaper. - Admin grids. UI component listings use their own data providers and collections. Forcing them through a repository adds work for no benefit.
- Indexers and exports. These usually read in batches by ID range with raw SQL, as shown in the custom indexer article.
The rule I follow is simple. Public behavior goes through a contract. Internal heavy lifting can use whatever is fastest, as long as it stays inside the module.
Common mistakes
- Calling
$model->load()and$model->save()directly. Both methods are deprecated onAbstractModel. Use the resource model or the repository. - Injecting the concrete repository class instead of the interface. That defeats preferences and makes plugins on the interface miss your calls.
- Returning arrays of mixed data from a service method. The Web API cannot serialize them reliably. Return data interfaces or scalar types with proper docblocks.
- Forgetting that plugins on repositories are common. Third party modules often add
afterGetorafterGetListplugins for extension attributes, which can make a repository call much slower than expected. Profile before assuming.
FAQ
Do I need a repository for every custom table?
No. If the data is only used inside your module and never exposed through APIs, a resource model and collection are fine. Add a service contract when other modules, REST or GraphQL need to work with the entity.
Why does my REST endpoint fail to serialize my data object?
Usually the docblocks are missing or use short class names. The Web API reads the @return and @param annotations, and they need fully qualified class names or scalar types.
How do I write an OR filter with SearchCriteriaBuilder?
Create the filters with FilterBuilder, put them in a single filter group with FilterGroupBuilder, and pass that group to setFilterGroups on the builder. Filters in the same group are combined with OR.
Is the product repository cache a problem?
In normal requests it helps. In long running CLI commands and queue consumers it can grow without limit, so use the forceReload argument or clear it between batches.
I am available for freelance Magento and Adobe Commerce work. If you need help designing service contracts or cleaning up a module's architecture, get in touch.
Related articles
Magento 2 Extension Attributes: Add Data to Orders and APIs
Add custom data to Magento 2 entities with extension attributes: extension_attributes.xml, repository plugins, storage, REST output and the usual pitfalls.
PWA Studio vs Custom Headless Magento: A Developer View
PWA Studio vs a custom headless Magento frontend: architecture, real costs, GraphQL gaps, SEO, checkout and payments, and when a server rendered theme wins.
AI Tools for Magento Development: What Works, What Fails
Using AI coding assistants on Magento 2 projects: where they save time, where they invent code, how to give them context and how to verify their output.
Writing Alpine.js Components in Hyvä: Patterns That Scale
How to write Alpine.js components on Hyvä: passing PHP data, view models, customer data events, form keys, component events, CSP and keeping templates clean.