Service Contracts and Repositories

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

A Magento module that stores something and lets others read it does not hand out its models. It publishes a service contract: a handful of PHP interfaces that say what can be asked and what comes back, and it keeps the classes behind them private. Every core module works this way (the product, the customer, the order, the cart, the store), which is why the same five repository methods appear everywhere, why REST and SOAP routes can be declared in one XML file, and why an extension can add a field to a product without touching the product module. This page builds a complete contract for a Pocket Coffee entity, the roast lot, shows how callers use it with SearchCriteria, exposes it on the web API and extends a core contract with an extension attribute.

The page was written against Magento Open Source and Adobe Commerce 2.4.9 (generally available 2026-05-12) and Mage-OS 3.6.0 (2026-10-14, built on the 2.4.9 code base) and checked on 2026-10-27. Service contracts are PHP in the shop’s process, so they apply to Magento Open Source, Adobe Commerce on premises or on cloud infrastructure, and Mage-OS; Adobe Commerce as a Cloud Service (ACCS) accepts no PHP modules, although its REST API is the web API of the same core contracts (see the editions page). The web API side is continued on the REST and GraphQL APIs page; the table behind the entity is created on the declarative schema and patches page.

All code and outputs come from a Mage-OS 3.6.0 installation in developer mode (PHP 8.5.12, MariaDB 11.8.9, OpenSearch 3.9.0, Valkey 9.1.3) with the module PocketCoffee_Lot. The module is the one used on the schema page: the table pocketcoffee_lot and its roast lots for the product POCKET-001 (Ethiopia, Colombia and Brazil from a data patch, and a Kenya lot added by hand) exist before this page starts.

What a service contract is

Adobe’s definition is short: a service contract is a set of PHP interfaces defined for a module, made of data interfaces, which preserve data integrity, and service interfaces, which hide business logic from the callers (controllers, web services and other modules). The two kinds live in two folders of the module, and the convention is strict:

Kind Folder Examples in the core

Data interface

Api/Data/

ProductInterface, CustomerInterface, OrderInterface, and the search result interfaces such as ProductSearchResultsInterface.

Repository interface

Api/

ProductRepositoryInterface, CustomerRepositoryInterface, OrderRepositoryInterface: persistent entities, always with save, get (or getById), getList, delete and deleteById.

Management interface

Api/

AccountManagementInterface (createAccount, changePassword, isEmailAvailable), CartManagementInterface (placeOrder): operations that are not CRUD.

Metadata interface

Api/

AttributeMetadataInterface, ProductMetadataInterface (getVersion, getEdition, getName).

The benefits are the reason the pattern is worth its boilerplate. Callers depend on interfaces with the @api marker, which Adobe’s documentation uses to indicate backward compatibility, so a module upgrade that keeps the interface does not break them. The XML of webapi.xml can expose any service method as REST and SOAP without code. And the model behind the interface is free to change (the table layout, the EAV attributes, even the storage) without callers noticing. The figure shows who may talk to whom.

Callers depend on the service contract in the Api folder, which is implemented by the Model folder and persisted by resource models

The class diagram of the contract built on this page:

classDiagram direction LR class ExtensibleDataInterface { <<interface>> getExtensionAttributes() } class LotInterface { <<interface>> getLotId() int getProductId() int getSku() string getOrigin() string getRoastedAt() string getWeightKg() float getStatus() int } class LotSearchResultsInterface { <<interface>> getItems() LotInterface[] getTotalCount() int getSearchCriteria() } class LotRepositoryInterface { <<interface>> save(lot) LotInterface getById(lotId) LotInterface getList(searchCriteria) LotSearchResultsInterface delete(lot) bool deleteById(lotId) bool } class Lot class LotRepository ExtensibleDataInterface <|-- LotInterface LotInterface <|.. Lot LotRepositoryInterface <|.. LotRepository LotRepositoryInterface ..> LotInterface : returns LotRepositoryInterface ..> LotSearchResultsInterface : returns LotSearchResultsInterface o-- LotInterface : items LotRepository ..> Lot : creates

The data interface

A data interface lists the getters and setters of an entity, and nothing else. LotInterface extends Magento\Framework\Api\ExtensibleDataInterface, which adds the extension attribute accessors used further down. The constants name the keys that become the JSON properties and the table columns; the status constants are part of the contract because callers need them.

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Api\Data;

use Magento\Framework\Api\ExtensibleDataInterface;

/**
 * One roast lot of a product. Data interface of the Lot service contract.
 *
 * @api
 */
interface LotInterface extends ExtensibleDataInterface
{
    public const LOT_ID = 'lot_id';
    public const PRODUCT_ID = 'product_id';
    public const SKU = 'sku';
    public const ORIGIN = 'origin';
    public const ROASTED_AT = 'roasted_at';
    public const WEIGHT_KG = 'weight_kg';
    public const STATUS = 'status';
    public const CREATED_AT = 'created_at';
    public const UPDATED_AT = 'updated_at';

    public const STATUS_ROASTING = 0;
    public const STATUS_READY = 1;
    public const STATUS_SOLD_OUT = 2;

    /**
     * @return int|null
     */
    public function getLotId();

    /**
     * @param int $lotId
     * @return $this
     */
    public function setLotId($lotId);

    /**
     * @return int
     */
    public function getProductId();

    /**
     * @param int $productId
     * @return $this
     */
    public function setProductId($productId);

    /**
     * @return string
     */
    public function getSku();

    /**
     * @param string $sku
     * @return $this
     */
    public function setSku($sku);

The rest of the interface repeats the pattern for the other columns and ends with the extension attribute accessors:

    /**
     * @return string|null
     */
    public function getCreatedAt();

    /**
     * @return string|null
     */
    public function getUpdatedAt();

    /**
     * @return \PocketCoffee\Lot\Api\Data\LotExtensionInterface|null
     */
    public function getExtensionAttributes();

    /**
     * @param \PocketCoffee\Lot\Api\Data\LotExtensionInterface $extensionAttributes
     * @return $this
     */
    public function setExtensionAttributes(\PocketCoffee\Lot\Api\Data\LotExtensionInterface $extensionAttributes);
}

Four rules turn this plain interface into something the Framework can serialize.

  • Docblocks are executable. The web API reads the @param and @return types from the docblock, because older PHP versions could not declare them, and it requires fully qualified class names even when a use statement exists. The failure is real: with the @return of getById shortened to LotInterface, a REST call answered {"message":"Class \"LotInterface\" does not exist","code":-1,...}; restoring the fully qualified name fixed it. Scalars are int, float, string, bool, mixed, and a list is the type followed by [], as in \...\LotInterface[].

  • Getters become keys. The conversion of an object to an array calls the getters and turns the method name into a snake case key (getRoastedAt() becomes roasted_at), and the reverse conversion calls the setters. Adobe’s guidance warns against underscores between letters and digits in column names for the same reason: default_shipping_1 would not survive the round trip because the method defaultShipping1 converts to default_shipping1.

  • Setters return $this. The design-pattern page states that service contract data interfaces are now mutable, which is why they have setters at all.

  • No logic. A data interface describes data; validation rules, if any, belong in the service behind it.

The search results interface

getList() does not return a bare array. It returns an object that carries the items, the total count regardless of paging and the criteria that produced them. The core provides one generic implementation, Magento\Framework\Api\SearchResults; a module declares its own interface only so that getItems() has a precise type, and maps it to the generic class with a preference.

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Api\Data;

use Magento\Framework\Api\SearchResultsInterface;

/**
 * Result of LotRepositoryInterface::getList().
 *
 * @api
 */
interface LotSearchResultsInterface extends SearchResultsInterface
{
    /**
     * @return \PocketCoffee\Lot\Api\Data\LotInterface[]
     */
    public function getItems();

    /**
     * @param \PocketCoffee\Lot\Api\Data\LotInterface[] $items
     * @return $this
     */
    public function setItems(array $items);
}

The repository interface

The repository is the service interface of a persistent entity. The design patterns require five methods, and the Pocket Coffee repository has exactly those:

Method Contract

save($entity)

Creates the record when the entity has no id and updates it when it has one. Returns the saved entity, reloaded.

getById($id) (or get($id))

Loads one entity, or throws NoSuchEntityException.

getList($searchCriteria)

Returns the search result object for the criteria.

delete($entity)

Deletes the entity and returns true, or throws CouldNotDeleteException.

deleteById($id)

Deletes by key and returns true.

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Api;

/**
 * Repository of roast lots. Service interface of the Lot service contract.
 *
 * @api
 */
interface LotRepositoryInterface
{
    /**
     * Create a lot (no lot_id) or update one (lot_id set).
     *
     * @param \PocketCoffee\Lot\Api\Data\LotInterface $lot
     * @return \PocketCoffee\Lot\Api\Data\LotInterface
     * @throws \Magento\Framework\Exception\CouldNotSaveException
     */
    public function save(\PocketCoffee\Lot\Api\Data\LotInterface $lot);

    /**
     * @param int $lotId
     * @return \PocketCoffee\Lot\Api\Data\LotInterface
     * @throws \Magento\Framework\Exception\NoSuchEntityException
     */
    public function getById($lotId);

    /**
     * @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria
     * @return \PocketCoffee\Lot\Api\Data\LotSearchResultsInterface
     */
    public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria);

    /**
     * @param \PocketCoffee\Lot\Api\Data\LotInterface $lot
     * @return bool true on success
     * @throws \Magento\Framework\Exception\CouldNotDeleteException
     */
    public function delete(\PocketCoffee\Lot\Api\Data\LotInterface $lot);

    /**
     * @param int $lotId
     * @return bool true on success
     * @throws \Magento\Framework\Exception\NoSuchEntityException
     * @throws \Magento\Framework\Exception\CouldNotDeleteException
     */
    public function deleteById($lotId);
}

The @throws tags are part of the contract. The web API turns NoSuchEntityException into HTTP 404 and the save and delete exceptions into 400, as shown below. A repository should also be stateless after construction: every call must be independent of the previous ones, and a cache, if the entity needs one, uses the registry pattern like the customer repository does.

The implementation

Behind the interfaces sit four small classes. None of them is part of the contract; callers never import them.

  • The model extends Magento\Framework\Model\AbstractExtensibleModel and implements LotInterface. _construct() binds it to its resource model. The accessors read and write the model’s data array and cast to the types of the interface; the id getter returns null for a new entity, so that save() can tell a creation from an update. The class also delegates the extension attribute accessors to the abstract class.

  • The resource model extends AbstractDb and names the table and the primary key.

  • The collection extends AbstractCollection and ties the model and the resource model together.

  • The repository implements LotRepositoryInterface with the three objects above and the collection processor.

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Model;

use Magento\Framework\Model\AbstractExtensibleModel;
use PocketCoffee\Lot\Api\Data\LotExtensionInterface;
use PocketCoffee\Lot\Api\Data\LotInterface;

class Lot extends AbstractExtensibleModel implements LotInterface
{
    /**
     * @inheritDoc
     */
    protected function _construct()
    {
        $this->_init(ResourceModel\Lot::class);
    }

    /**
     * @inheritDoc
     */
    public function getLotId()
    {
        return $this->getData(self::LOT_ID) === null ? null : (int) $this->getData(self::LOT_ID);
    }

    /**
     * @inheritDoc
     */
    public function setLotId($lotId)
    {
        return $this->setData(self::LOT_ID, $lotId);
    }

    /**
     * @inheritDoc
     */
    public function getProductId()
    {
        return (int) $this->getData(self::PRODUCT_ID);
    }

    /**
     * @inheritDoc
     */
    public function setProductId($productId)
    {
        return $this->setData(self::PRODUCT_ID, $productId);
    }
    /**
     * @inheritDoc
     */
    public function getExtensionAttributes()
    {
        return $this->_getExtensionAttributes();
    }

    /**
     * @inheritDoc
     */
    public function setExtensionAttributes(LotExtensionInterface $extensionAttributes)
    {
        return $this->_setExtensionAttributes($extensionAttributes);
    }
}
<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Model\ResourceModel;

use Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class Lot extends AbstractDb
{
    /**
     * @inheritDoc
     */
    protected function _construct()
    {
        $this->_init('pocketcoffee_lot', 'lot_id');
    }
}
<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Model\ResourceModel\Lot;

use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use PocketCoffee\Lot\Model\Lot;
use PocketCoffee\Lot\Model\ResourceModel\Lot as LotResource;

class Collection extends AbstractCollection
{
    /**
     * @inheritDoc
     */
    protected function _construct()
    {
        $this->_init(Lot::class, LotResource::class);
    }
}

The repository itself is where the contract meets the persistence layer. The collection processor (an interface that the core maps to Magento\Framework\Api\SearchCriteria\CollectionProcessor, which dev:di:info confirms) applies the filters, the sort orders and the paging of a SearchCriteria to a collection, so the repository needs no query code. The factories (LotFactory, CollectionFactory, LotSearchResultsInterfaceFactory) are generated classes created on first use; the page on dependency injection explains them.

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Model;

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;
use PocketCoffee\Lot\Api\Data\LotInterface;
use PocketCoffee\Lot\Api\Data\LotSearchResultsInterface;
use PocketCoffee\Lot\Api\Data\LotSearchResultsInterfaceFactory;
use PocketCoffee\Lot\Api\LotRepositoryInterface;
use PocketCoffee\Lot\Model\ResourceModel\Lot as LotResource;
use PocketCoffee\Lot\Model\ResourceModel\Lot\CollectionFactory;

class LotRepository implements LotRepositoryInterface
{
    public function __construct(
        private readonly LotResource $resource,
        private readonly LotFactory $lotFactory,
        private readonly CollectionFactory $collectionFactory,
        private readonly LotSearchResultsInterfaceFactory $searchResultsFactory,
        private readonly CollectionProcessorInterface $collectionProcessor
    ) {
    }

    /**
     * @inheritDoc
     */
    public function save(LotInterface $lot)
    {
        try {
            $this->resource->save($lot);
        } catch (\Exception $e) {
            throw new CouldNotSaveException(__('Could not save the lot: %1', $e->getMessage()), $e);
        }
        return $this->getById($lot->getLotId());
    }

    /**
     * @inheritDoc
     */
    public function getById($lotId)
    {
        $lot = $this->lotFactory->create();
        $this->resource->load($lot, $lotId);
        if (!$lot->getLotId()) {
            throw new NoSuchEntityException(__('The lot with ID "%1" does not exist.', $lotId));
        }
        return $lot;
    }

    /**
     * @inheritDoc
     */
    public function getList(SearchCriteriaInterface $searchCriteria)
    {
        $collection = $this->collectionFactory->create();
        $this->collectionProcessor->process($searchCriteria, $collection);

        /** @var LotSearchResultsInterface $searchResults */
        $searchResults = $this->searchResultsFactory->create();
        $searchResults->setSearchCriteria($searchCriteria);
        $searchResults->setItems($collection->getItems());
        $searchResults->setTotalCount($collection->getSize());
        return $searchResults;
    }

    /**
     * @inheritDoc
     */
    public function delete(LotInterface $lot)
    {
        try {
            $this->resource->delete($lot);
        } catch (\Exception $e) {
            throw new CouldNotDeleteException(__('Could not delete the lot: %1', $e->getMessage()), $e);
        }
        return true;
    }

    /**
     * @inheritDoc
     */
    public function deleteById($lotId)
    {
        return $this->delete($this->getById($lotId));
    }
}

The module then maps every interface to its class in etc/di.xml, the only place where the interfaces and the model meet. The preference for LotSearchResultsInterface points to the core class, and the product plugin and the console commands (see below) are registered in the same file.

<?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="PocketCoffee\Lot\Api\Data\LotInterface" type="PocketCoffee\Lot\Model\Lot"/>
    <preference for="PocketCoffee\Lot\Api\Data\LotSearchResultsInterface" type="Magento\Framework\Api\SearchResults"/>
    <preference for="PocketCoffee\Lot\Api\LotRepositoryInterface" type="PocketCoffee\Lot\Model\LotRepository"/>

    <type name="Magento\Catalog\Api\ProductRepositoryInterface">
        <plugin name="pocketcoffee_lot_latest_lot" type="PocketCoffee\Lot\Plugin\ProductLatestLotPlugin"/>
    </type>

    <type name="Magento\Framework\Console\CommandListInterface">
        <arguments>
            <argument name="commands" xsi:type="array">
                <item name="pocketcoffee_lot_list" xsi:type="object">PocketCoffee\Lot\Console\Command\ListCommand</item>
                <item name="pocketcoffee_lot_add" xsi:type="object">PocketCoffee\Lot\Console\Command\AddCommand</item>
            </argument>
        </arguments>
    </type>
</config>

Two details of the repository are worth copying into every module. The exceptions are the Framework’s own (CouldNotSaveException, NoSuchEntityException, CouldNotDeleteException) with the original exception as the previous one, because the web API and the admin map those to HTTP codes and messages. And save() returns getById(), so the caller gets the database defaults (created_at, updated_at) and the id.

Using a repository

A class that needs lots asks for LotRepositoryInterface in its constructor and nothing else. It never creates a model, a collection or a resource model, and it never calls the ObjectManager. Reading uses a SearchCriteria, built with the builders that the Framework provides.

SearchCriteria: filters, groups, sorting and paging

A SearchCriteria is made of filter groups, sort orders, a page size and a current page. A Filter is a field, a value and a condition type. The rule that decides everything: filters inside one group are joined with OR, and groups are joined with AND. The core says it as (A OR B) AND ©. It is therefore impossible to write (A AND B) OR (C AND D) in one criteria. The command below applies the rule to Pocket Coffee: the user passes any number of --origin options (one group, joined by OR) and an optional --since date (a second group, joined by AND), plus a sort order and the paging.

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        // filters inside one group are joined with OR, groups are joined with AND
        $groups = [];
        $origins = (array) $input->getOption('origin');
        if ($origins) {
            $filters = [];
            foreach ($origins as $origin) {
                $filters[] = $this->filterBuilder->setField(LotInterface::ORIGIN)
                    ->setConditionType('eq')->setValue($origin)->create();
            }
            $groups[] = $this->filterGroupBuilder->setFilters($filters)->create();
        }
        if ($input->getOption('since')) {
            $groups[] = $this->filterGroupBuilder->setFilters([
                $this->filterBuilder->setField(LotInterface::ROASTED_AT)
                    ->setConditionType('gteq')->setValue($input->getOption('since'))->create(),
            ])->create();
        }
        $criteria = $this->searchCriteriaBuilder
            ->setFilterGroups($groups)
            ->addSortOrder($this->sortOrderBuilder->setField(LotInterface::ROASTED_AT)
                ->setDirection(SortOrder::SORT_DESC)->create())
            ->setPageSize((int) $input->getOption('page-size'))
            ->setCurrentPage((int) $input->getOption('page'))
            ->create();

        $result = $this->lotRepository->getList($criteria);
        $output->writeln(sprintf('total_count: %d, returned: %d', $result->getTotalCount(), count($result->getItems())));
        foreach ($result->getItems() as $lot) {
            $output->writeln(sprintf(
                '#%d %s %s roasted %s %.3f kg status %d',
                $lot->getLotId(),
                $lot->getSku(),
                $lot->getOrigin(),
                $lot->getRoastedAt(),
                $lot->getWeightKg(),
                $lot->getStatus()
            ));
        }
        return Command::SUCCESS;
    }
}

The builders are injected objects (SearchCriteriaBuilder, FilterBuilder, FilterGroupBuilder, SortOrderBuilder) that collect state; create() returns the immutable object and resets the builder. Forgetting create(), or sharing a builder between two criteria, leaks filters from one query into the next, which is why the documentation’s example uses the builders and not new. These are the results:

$ bin/magento pocketcoffee:lot:list
total_count: 4, returned: 4
#7 POCKET-001 Kenya roasted 2026-10-27 8.500 kg status 0
#3 POCKET-001 Brazil roasted 2026-10-26 15.250 kg status 0
#2 POCKET-001 Colombia roasted 2026-10-05 20.000 kg status 1
#1 POCKET-001 Ethiopia roasted 2026-09-14 12.500 kg status 2
$ bin/magento pocketcoffee:lot:list --origin=Ethiopia --origin=Brazil
total_count: 2, returned: 2
#3 POCKET-001 Brazil roasted 2026-10-26 15.250 kg status 0
#1 POCKET-001 Ethiopia roasted 2026-09-14 12.500 kg status 2
$ bin/magento pocketcoffee:lot:list --origin=Ethiopia --origin=Brazil --since=2026-10-01
total_count: 1, returned: 1
#3 POCKET-001 Brazil roasted 2026-10-26 15.250 kg status 0
$ bin/magento pocketcoffee:lot:list --page-size=2 --page=2
total_count: 4, returned: 2
#2 POCKET-001 Colombia roasted 2026-10-05 20.000 kg status 1
#1 POCKET-001 Ethiopia roasted 2026-09-14 12.500 kg status 2

The first run has no filter and returns every lot, newest first. The second ORs two origins. The third adds the date group, an AND, and only the Brazil lot remains. The fourth asks for the second page of two: total_count still says 4, because it counts the rows that match regardless of the page.

The condition types are the same in PHP (setConditionType()) and on the web API. They are the keys the SQL layer understands:

Condition Meaning

eq, neq

Equals, not equals. eq is the default when the type is missing.

like, nlike

SQL LIKE and NOT LIKE; the value carries the wildcards (Eth%; %25 on a URL).

in, nin

In or not in a list; the value is a comma-separated string.

gt, gteq, moreq, lt, lteq

Greater than, greater than or equal, less than, less than or equal. The documentation also lists moreq (more or equal).

from, to

The two ends of a range, used as a pair.

null, notnull

Column is NULL or is not NULL.

finset, nfinset

The value is, or is not, within a comma-separated set stored in the column.

Most of them were run against the lots over REST (like with Eth%25, in, neq, gteq, gt, nin, lteq, null, notnull and from); moreq, to, finset, nlike and nfinset come from the documentation and were not run. The behaviour at the edges, also observed:

  • A page beyond the last returns an empty items array and the true total_count (page 9 of size 2 over 4 lots).

  • No pageSize returns every match. A repository that backs a public endpoint should cap the page size itself; the search engine side has a result window of 10000 by default, as the documentation states for OpenSearch based repositories.

  • A filter or a sort on a column that does not exist is not validated by the processor. The query fails with a database error and the REST call answers 400 with the SQL message (Unknown column 'nope' in 'WHERE'). The message is shown because the shop is in developer mode; in production mode the web API masks the details of unexpected exceptions behind a report id.

  • Only top-level fields can be filtered. A field that lives in another table needs a custom filter or a join, described next.

Collection processors, custom filters and joins

CollectionProcessor is a composite of three processors, FilterProcessor, SortingProcessor and PaginationProcessor, configured in di.xml. A module with special needs declares its own virtual types: a field mapping when the criteria field differs from the column (code to customer_group_code), a custom filter class implementing CustomFilterInterface for a condition that is not a plain column comparison (the core uses one for category_id on products), default sort orders, and a JoinProcessor with CustomJoinInterface classes for joined tables. Collections of EAV entities have to use the EAV variant of the filter processor, or the custom filters are not applied. The Pocket Coffee repository needs none of this because its filters are columns of its own table.

Creating and updating

Creation goes through the generated factory of the interface (LotInterfaceFactory, resolved by the preference to the model) and DataObjectHelper::populateWithArray(), which fills the object through the setters of the interface rather than the model’s data array. The command below creates a lot for POCKET-001 and shows what happens when a unique key is violated:

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        /** @var LotInterface $lot */
        $lot = $this->lotFactory->create();
        // fills the object through the setters of the interface
        $this->dataObjectHelper->populateWithArray($lot, [
            LotInterface::PRODUCT_ID => (int) $input->getArgument('product-id'),
            LotInterface::SKU => $input->getArgument('sku'),
            LotInterface::ORIGIN => $input->getArgument('origin'),
            LotInterface::ROASTED_AT => $input->getArgument('roasted-at'),
            LotInterface::WEIGHT_KG => (float) $input->getArgument('weight-kg'),
            LotInterface::STATUS => LotInterface::STATUS_ROASTING,
        ], LotInterface::class);
        try {
            $saved = $this->lotRepository->save($lot);
            $output->writeln(sprintf('saved lot #%d (%s)', $saved->getLotId(), get_class($saved)));
            return Command::SUCCESS;
        } catch (CouldNotSaveException $e) {
            $output->writeln('error: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }
}
$ bin/magento pocketcoffee:lot:add 1 POCKET-001 Peru 2026-10-28 5.5
saved lot #12 (PocketCoffee\Lot\Model\Lot)

$ bin/magento pocketcoffee:lot:add 1 POCKET-001 Kenya 2026-10-27 3
error: Could not save the lot: Unique constraint violation found

The second run broke the unique constraint (sku and roasted_at) of the table, and the caller received a CouldNotSaveException with a readable message instead of a database error. An update is the same call with the id set: load the entity with getById(), change it through the setters and pass it to save().

Calling a core repository

The same builders work on every repository. A search of the product repository for the Pocket Coffee beans, from PHP, is:

$criteria = $this->searchCriteriaBuilder
    ->addFilter('sku', 'POCKET%', 'like')
    ->setPageSize(20)
    ->create();
$products = $this->productRepository->getList($criteria)->getItems();

addFilter() of the builder puts each call into a group of its own, so several calls are ANDed; addFilters([...]) puts an array of filters into one group, which makes them ORed. The product repository returns products with their extension attributes, which is the next topic.

Exposing the contract on the web API

A service method becomes a REST and SOAP endpoint with one XML element. The module’s etc/webapi.xml maps four routes to the repository, protected by an ACL resource declared in etc/acl.xml:

<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
    <route url="/V1/pocketcoffee/lots/:lotId" method="GET">
        <service class="PocketCoffee\Lot\Api\LotRepositoryInterface" method="getById"/>
        <resources>
            <resource ref="PocketCoffee_Lot::lots"/>
        </resources>
    </route>
    <route url="/V1/pocketcoffee/lots" method="GET">
        <service class="PocketCoffee\Lot\Api\LotRepositoryInterface" method="getList"/>
        <resources>
            <resource ref="PocketCoffee_Lot::lots"/>
        </resources>
    </route>
    <route url="/V1/pocketcoffee/lots" method="POST">
        <service class="PocketCoffee\Lot\Api\LotRepositoryInterface" method="save"/>
        <resources>
            <resource ref="PocketCoffee_Lot::lots"/>
        </resources>
    </route>
    <route url="/V1/pocketcoffee/lots/:lotId" method="DELETE">
        <service class="PocketCoffee\Lot\Api\LotRepositoryInterface" method="deleteById"/>
        <resources>
            <resource ref="PocketCoffee_Lot::lots"/>
        </resources>
    </route>
</routes>
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
    <acl>
        <resources>
            <resource id="Magento_Backend::admin">
                <resource id="PocketCoffee_Lot::lots" title="Pocket Coffee roast lots" sortOrder="200"/>
            </resource>
        </resources>
    </acl>
</config>

The route attributes are url, which must start with /V1 and marks path parameters with a colon (:lotId, matched to the method parameter of the same name), and method, one of GET, POST, PUT and DELETE. The service element names the interface and the method, never the implementation, so the preference decides which class answers. The resources element lists who may call: a resource of etc/acl.xml (admin roles and integrations are granted it), self (the customer reads their own data) or anonymous. A data element with parameter children forces request parameters, for example to take the customer id from the token.

The mapping rules for the HTTP side:

  • A GET route that ends in getList takes the criteria from the query string, in the form searchCriteria[filterGroups][0][filters][0][field]=origin with value and conditionType (also written condition_type) next to it. The parameter is required: a request without any searchCriteria answered 400 with "searchCriteria" is required. Enter and try again.

  • POST, PUT and DELETE take their parameters from the JSON body, keyed by the parameter name of the method, so saving a lot is {"lot": {...}}.

  • The response is the serialized data interface. The extension attributes appear under extension_attributes when they are set.

  • All of the routes above were granted to an admin token with every resource; an anonymous call to GET /V1/pocketcoffee/lots/1 answered 401 with The consumer isn't authorized to access %resources.

Examples against the local shop (the token comes from the admin token endpoint, with the local test user; the value is never printed):

curl -s -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  "http://localhost:8082/rest/V1/pocketcoffee/lots/2"
{"lot_id":2,"product_id":1,"sku":"POCKET-001","origin":"Colombia","roasted_at":"2026-10-05","weight_kg":20,"status":1,"created_at":"2026-10-27 17:11:04","updated_at":"2026-10-27 17:11:04"}

A search with an OR group, a sort order and a page size, written without the URL escaping that curl needs for the brackets (-g turns the globbing off):

curl -s -g -H "Authorization: Bearer ${ADMIN_TOKEN}" \
  "http://localhost:8082/rest/V1/pocketcoffee/lots?\
searchCriteria[filterGroups][0][filters][0][field]=origin&\
searchCriteria[filterGroups][0][filters][0][value]=Ethiopia&\
searchCriteria[filterGroups][0][filters][1][field]=origin&\
searchCriteria[filterGroups][0][filters][1][value]=Brazil&\
searchCriteria[sortOrders][0][field]=roasted_at&\
searchCriteria[sortOrders][0][direction]=DESC&\
searchCriteria[pageSize]=10"
{"items":[{"lot_id":3,"product_id":1,"sku":"POCKET-001","origin":"Brazil","roasted_at":"2026-10-26","weight_kg":15.25,"status":0,"created_at":"2026-10-27 17:11:04","updated_at":"2026-10-27 17:11:04"},{"lot_id":1,"product_id":1,"sku":"POCKET-001","origin":"Ethiopia","roasted_at":"2026-09-14","weight_kg":12.5,"status":2,"created_at":"2026-10-27 17:11:04","updated_at":"2026-10-27 17:11:04"}],"search_criteria":{"filter_groups":[{"filters":[{"field":"origin","value":"Ethiopia","condition_type":"eq"},{"field":"origin","value":"Brazil","condition_type":"eq"}]}],"sort_orders":[{"field":"roasted_at","direction":"DESC"}],"page_size":10},"total_count":2}

Creating and updating use the same POST (the id in the body selects the update); the failures of the repository map to HTTP codes as follows:

Call HTTP Answer (developer mode)

GET /V1/pocketcoffee/lots/999

404

The lot with ID "%1" does not exist. with the parameter [999].

POST /V1/pocketcoffee/lots with a duplicate sku and date

400

Could not save the lot: %1 with the parameter Unique constraint violation found.

POST /V1/pocketcoffee/lots with an unknown product id

400

Could not save the lot: %1 with the foreign key violation text of the database.

DELETE /V1/pocketcoffee/lots/4, then GET /V1/pocketcoffee/lots/4

200, then 404

true, then the not found message.

Any call without a token

401

The consumer isn't authorized to access %resources.

The message templates keep their %1 placeholders with the values in parameters, which is how the web API localizes errors on the client side.

SOAP comes with the route. The service name is the module and the interface in camel case with a version suffix, and the WSDL lists one operation per route:

$ curl -s "http://localhost:8082/soap?wsdl_list=1" | grep -o pocketCoffeeLotLotRepositoryV1 | sort -u
pocketCoffeeLotLotRepositoryV1
$ curl -s -H "Authorization: Bearer ${ADMIN_TOKEN}" \
    "http://localhost:8082/soap/default?wsdl&services=pocketCoffeeLotLotRepositoryV1" \
    | grep -oE 'name="pocketCoffeeLotLotRepositoryV1(DeleteById|GetById|GetList|Save)"' | sort -u
name="pocketCoffeeLotLotRepositoryV1DeleteById"
name="pocketCoffeeLotLotRepositoryV1GetById"
name="pocketCoffeeLotLotRepositoryV1GetList"
name="pocketCoffeeLotLotRepositoryV1Save"

The generated types are PocketCoffeeLotDataLotInterface, PocketCoffeeLotDataLotExtensionInterface and PocketCoffeeLotDataLotSearchResultsInterface. The WSDL needs a token that holds the resource, like the REST routes.

GraphQL is not generated from contracts: a GraphQL field needs a schema.graphqls entry and a resolver that calls the repository. That is the subject of the REST and GraphQL page.

Constructors of web API classes

Since the fix for the SessionReaper bulletin APSB25-88, the REST layer validates the constructor parameters of the class that implements a service interface. Adobe’s documentation lists the supported parameter types as simple types and *\Api\Data\*Interface classes, and the unsupported ones as models, service classes and other complex types, which "will not be instantiated from REST payloads"; developers of earlier REST APIs are asked to review service implementations for unsupported constructor parameters. Adobe states that 2.4.9 and all prior supported versions were patched. LotRepository takes a resource model, factories and a collection processor in its constructor, and every REST call above worked: those arguments come from the dependency injection configuration and not from request data, so the validation concerns what a payload may fill in, not what the container injects. Keep payload-facing classes to data interfaces, and keep services free of constructor parameters that a request could influence. The security consequences are on the security page.

Extension attributes

Several modules cannot all add columns to the product, and they must not edit ProductInterface. The extension attribute is the mechanism that lets any module add a field to a data interface that implements ExtensibleDataInterface. It is declared in etc/extension_attributes.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
    <extension_attributes for="Magento\Catalog\Api\Data\ProductInterface">
        <attribute code="latest_lot" type="PocketCoffee\Lot\Api\Data\LotInterface">
            <resources>
                <resource ref="PocketCoffee_Lot::lots"/>
            </resources>
        </attribute>
    </extension_attributes>
</config>

The element names the interface to extend (for), the attribute code in snake case (code) and its type, a scalar or a data interface (type); a resources child restricts the attribute to roles with one of the listed ACL resources, which is the one place where the web API can hide a field from some callers. A join child, with reference_table, reference_field, join_on_field and field elements, tells the Framework to fill the attribute from a joined table when a collection runs through the extension attribute join processor, and makes the attribute searchable (the documentation shows stock_item of the product with its qty). The page did not run a join attribute; the Pocket Coffee attribute is filled by code.

What Magento generates

The Framework reads all the extension_attributes.xml files at generation time and writes an interface and a class for every extensible interface, even those without any attribute. After the first REST call that read a product, the product extension interface had the new accessors:

// generated/code/Magento/Catalog/Api/Data/ProductExtensionInterface.php (excerpt)
    /**
     * @return \PocketCoffee\Lot\Api\Data\LotInterface|null
     */
    public function getLatestLot();

    /**
     * @param \PocketCoffee\Lot\Api\Data\LotInterface $latestLot
     * @return $this
     */
    public function setLatestLot(\PocketCoffee\Lot\Api\Data\LotInterface $latestLot);

The module’s own entity has an empty LotExtensionInterface (generated because LotInterface is extensible), ready for the next module that wants to add something to a lot. Because the code is generated, nothing outside generated/ has to be written.

Filling the attribute

Declaring the attribute does not populate it. For a core interface the usual route is a plugin on the repository that loads the data: the page on plugins explains the mechanism and its limits. This one adds the newest lot to every product read through the product repository:

<?php
declare(strict_types=1);

namespace PocketCoffee\Lot\Plugin;

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\Data\ProductSearchResultsInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\FilterBuilder;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SortOrder;
use Magento\Framework\Api\SortOrderBuilder;
use PocketCoffee\Lot\Api\Data\LotInterface;
use PocketCoffee\Lot\Api\LotRepositoryInterface;

/**
 * Fills the latest_lot extension attribute of products read through the product repository.
 */
class ProductLatestLotPlugin
{
    public function __construct(
        private readonly LotRepositoryInterface $lotRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
        private readonly FilterBuilder $filterBuilder,
        private readonly SortOrderBuilder $sortOrderBuilder
    ) {
    }

    public function afterGet(ProductRepositoryInterface $subject, ProductInterface $product): ProductInterface
    {
        return $this->addLatestLot($product);
    }

    public function afterGetById(ProductRepositoryInterface $subject, ProductInterface $product): ProductInterface
    {
        return $this->addLatestLot($product);
    }

    public function afterGetList(
        ProductRepositoryInterface $subject,
        ProductSearchResultsInterface $searchResults
    ): ProductSearchResultsInterface {
        foreach ($searchResults->getItems() as $product) {
            $this->addLatestLot($product);
        }
        return $searchResults;
    }

    private function addLatestLot(ProductInterface $product): ProductInterface
    {
        $extension = $product->getExtensionAttributes();
        if ($extension === null || $extension->getLatestLot() !== null) {
            return $product;
        }
        $filter = $this->filterBuilder->setField(LotInterface::SKU)
            ->setConditionType('eq')
            ->setValue($product->getSku())
            ->create();
        $sort = $this->sortOrderBuilder->setField(LotInterface::ROASTED_AT)
            ->setDirection(SortOrder::SORT_DESC)
            ->create();
        $criteria = $this->searchCriteriaBuilder->addFilters([$filter])
            ->addSortOrder($sort)
            ->setPageSize(1)
            ->create();
        $items = $this->lotRepository->getList($criteria)->getItems();
        if ($items) {
            $extension->setLatestLot(reset($items));
        }
        return $product;
    }
}

The plugin is declared in etc/di.xml on the interface, Magento\Catalog\Api\ProductRepositoryInterface, with after methods for get, getById and getList. It reuses the module’s own repository and builders instead of querying the table, so it stays inside the contract. The effect is visible on the web API, for an admin token, as the latest_lot entry among the extension attributes of the product POCKET-001:

{
  "sku": "POCKET-001",
  "extension_attributes": {
    "website_ids": [1],
    "stock_item": { "item_id": 1, "qty": 96, "is_in_stock": true, "...": "..." },
    "latest_lot": {
      "lot_id": 3, "product_id": 1, "sku": "POCKET-001", "origin": "Brazil",
      "roasted_at": "2026-10-26", "weight_kg": 15.25, "status": 0
    }
  }
}

A search of the product list by sku like POCKET% returned one product with the same latest_lot (the Brazil lot), because the plugin also covers getList.

Things to know before relying on an extension attribute:

  • It is read-only here. Saving a product with a latest_lot in the body does nothing, because no code persists it. An attribute that can be written needs a plugin on save (or an observer) that stores it, and a decision about what a partial update means.

  • It costs a query per product in getList. The plugin runs once per item, an N+1 pattern that is acceptable for a page of 20 products and not for an export of 20,000. The join form of the attribute exists to avoid that.

  • The ACL resource decides who sees it. The attribute is bound to the resource PocketCoffee_Lot::lots, so a role that can read products but has no right to the lots would not receive it. This was checked with the all-resources admin only; a restricted role was not created.

  • It is not a custom attribute. Custom attributes (custom_attributes) are EAV attributes that a merchant creates in the admin, stored in the entity’s value tables; extension attributes are defined in code, may be complex types, and do not appear in the admin.

Management and metadata interfaces

Not everything is CRUD. The core publishes operations as management interfaces (CartManagementInterface::placeOrder, AccountManagementInterface::createAccount), with the same rules: data interfaces for parameters and results, the docblock rules, webapi.xml for exposure. The shipping quote of the plugins page is a small management interface, exposed on the web API with the same XML. A metadata interface answers questions about the platform or about attributes (ProductMetadataInterface returns the version, the edition and the name). When a class does more than one of these jobs, split it: a repository that also computes prices is no longer a repository.

The code a contract replaces

Three of the 2016 books cover the layers below a contract: the Magento 2 Cookbook has a recipe called "Working with database models", the Development Essentials a chapter on Entity-Attribute-Value, and the DIY title a chapter on the REST and SOAP APIs. The model, the resource model and the collection of this page are those layers, and they are still there, behind the contract. What a current project adds is the discipline: callers use the repository and the data interface, and the model is an implementation detail. Code that loads \Magento\Catalog\Model\Product with a collection works, but it is the pattern that breaks first when the platform changes a model, whereas the contract is what Adobe commits to keep.

Pocket Coffee thread

The roast lot is the shop’s record of what was roasted and when: the origin, the roast date, the weight and a status. The roast date badge of the product page can read the newest ready lot through this contract (the latest_lot extension attribute is already on the product), the order-placed observer of the plugins page could attach the lot to an order line, and a warehouse integration can read and write lots over REST with an integration token that holds only PocketCoffee_Lot::lots. None of them has to know the table.

Pitfalls

Symptom Cause and remedy

Class "X" does not exist from the web API

A docblock of the service interface uses a short class name. Use the fully qualified name in every @param and @return.

Properties missing in the JSON

The interface has no getter for the property, or the name does not survive the camel case to snake case round trip.

searchCriteria is required on a list route

The query string has no searchCriteria keys at all. Send at least searchCriteria[pageSize]=20.

A search returns everything

An empty filter list means no filter, and a missing pageSize means no limit. Cap the page size in the repository.

An unknown field answers with an SQL error

The collection processor does not validate fields. Whitelist the searchable fields in the repository or in a field mapping.

(A AND B) OR (C AND D) is impossible

Groups are ANDed, filters in a group are ORed. Run two searches and merge, or build a custom filter.

getList() of a repository with an extension attribute is slow

The plugin or the join runs per item. Batch the lookups for the page.

A new extension attribute never appears

The attribute needs generated classes and a regenerated interceptor for the plugin: clear generated/ (setup:upgrade) and flush the cache.

Callers import Model\Lot

The contract leaks. Type to LotInterface and LotRepositoryInterface.

What was and was not run

Run on the local Mage-OS 3.6.0 Docker shop: php -l and the Magento2 PHP_CodeSniffer standard on the module (no errors; line length warnings in the XML headers, and one warning about the raw SQL of the schema patch described on the next page), xmllint against the XSDs for db_schema.xml, di.xml, webapi.xml, acl.xml, extension_attributes.xml and module.xml; the console commands for listing and creating lots; REST GET by id, search with filter groups, sort order and page size, POST for create and update, DELETE, the error cases of the table above, an anonymous call, and the product with its extension attribute (by sku and through a product search); the WSDL of the generated SOAP service with an admin token; the experiment with a short class name in a docblock; and the condition types listed as run. Not run: a SOAP call (only the WSDL was read), a join extension attribute, an extension attribute restricted for a role that lacks the resource, a custom filter or join processor, GraphQL, and a save of a product with an extension attribute.