Service Contracts as the Basis for REST, SOAP, and GraphQL
Service Contracts as the Basis for REST, SOAP, and GraphQL: Api\Data\RewardInterface and RewardRepositoryInterface
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
Block 9 built an entire new product type without touching a single line from block 1 - service contracts like PointsLedgerRepositoryInterface and CustomerRepositoryInterface were simply already there, ready to reuse. Block 10 turns that idea into the headline topic: before any REST, SOAP or GraphQL endpoint can exist, the reward entity from block 2 finally needs exactly the layer the points ledger has had since chapter 6.
Why block 2 deliberately left these classes out
Chapters 10-18 built Model\Reward, ResourceModel\Reward, and ResourceModel\Reward\Collection - enough for the admin grid (chapter 16) and storefront view models (chapter 49), all of which run inside the same PHP process and are allowed to touch the EAV model directly. But as soon as an external client - a mobile frontend, a partner system, the same GraphQL request block 10 is about to build - needs to reach a reward, that's no longer enough: Magento\Framework\Model\AbstractModel is never a serializable contract, and getData()/setData() know neither fixed field names nor a stable type. That's exactly what the Api/Api\Data namespace pair, already demonstrated by PointsLedgerInterface (chapter 6), exists for - this chapter now applies the same pattern to rewards.
The data interface: Api\Data\RewardInterface
Unlike PointsLedgerInterface, RewardInterface deliberately extends ExtensibleDataInterface - the same base Magento's own ProductInterface/CategoryInterface use. The reason: rewards are a publicly extensible entity (any third-party module can later hook in extra fields via its own extension_attributes.xml, see chapter 85), while the ledger entry from chapter 6 stays a purely internal, immutable log object that should never be extended.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api\Data;
use Magento\Framework\Api\ExtensibleDataInterface;
/**
* Data interface for a single loyalty reward - the read/write contract every
* REST, SOAP and GraphQL entry point in this module builds on from here on.
*/
interface RewardInterface extends ExtensibleDataInterface
{
public const REWARD_ID = 'reward_id';
public const IDENTIFIER = 'identifier';
public const TITLE = 'title';
public const DESCRIPTION = 'description';
public const POINTS_COST = 'points_cost';
public const DISCOUNT_VALUE = 'discount_value';
public const REWARD_TYPE = 'reward_type';
public const IS_ACTIVE = 'is_active';
/**
* Returns the reward's entity id, or null for a not-yet-saved reward.
*
* @return int|null
*/
public function getRewardId(): ?int;
/**
* Sets the reward's entity id.
*
* @param int $rewardId Reward entity ID.
* @return $this
*/
public function setRewardId(int $rewardId): self;
/**
* Returns the URL-safe, sku-like identifier used in storefront routes (chapter 46).
*
* @return string
*/
public function getIdentifier(): string;
/**
* Sets the URL-safe, sku-like identifier.
*
* @param string $identifier Unique reward identifier.
* @return $this
*/
public function setIdentifier(string $identifier): self;
/**
* Returns the reward's display title.
*
* @return string
*/
public function getTitle(): string;
/**
* Sets the reward's display title.
*
* @param string $title Reward title.
* @return $this
*/
public function setTitle(string $title): self;
/**
* Returns the reward's description, or null if none was set.
*
* @return string|null
*/
public function getDescription(): ?string;
/**
* Sets the reward's description.
*
* @param string|null $description Reward description.
* @return $this
*/
public function setDescription(?string $description): self;
/**
* Returns how many points redeeming this reward costs.
*
* @return int
*/
public function getPointsCost(): int;
/**
* Sets how many points redeeming this reward costs.
*
* @param int $pointsCost Points cost.
* @return $this
*/
public function setPointsCost(int $pointsCost): self;
/**
* Returns the discount value, or null for reward types that don't use one.
*
* @return float|null
*/
public function getDiscountValue(): ?float;
/**
* Sets the discount value.
*
* @param float|null $discountValue Discount value.
* @return $this
*/
public function setDiscountValue(?float $discountValue): self;
/**
* Returns the reward type, one of Model\Reward\Source\RewardType::TYPE_*.
*
* @return string
*/
public function getRewardType(): string;
/**
* Sets the reward type.
*
* @param string $rewardType One of Model\Reward\Source\RewardType::TYPE_*.
* @return $this
*/
public function setRewardType(string $rewardType): self;
/**
* Returns whether the reward is currently redeemable.
*
* @return bool
*/
public function isActive(): bool;
/**
* Sets whether the reward is currently redeemable.
*
* @param bool $isActive Active flag.
* @return $this
*/
public function setIsActive(bool $isActive): self;
/**
* Returns the reward's extension attributes.
*
* @return \Mironsoft\Loyalty\Api\Data\RewardExtensionInterface|null
*/
public function getExtensionAttributes(): ?RewardExtensionInterface;
/**
* Sets the reward's extension attributes.
*
* @param \Mironsoft\Loyalty\Api\Data\RewardExtensionInterface $extensionAttributes Extension attributes.
* @return $this
*/
public function setExtensionAttributes(RewardExtensionInterface $extensionAttributes): self;
}
ExtensibleDataInterface alone isn't enough - Magento's code generator only builds the matching RewardExtensionInterface once a (initially empty) extension_attributes.xml registers the entity for it:
<?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="Mironsoft\Loyalty\Api\Data\RewardInterface">
</extension_attributes>
</config>
The return object: Model\Data\Reward instead of Model\Reward directly
Model\Reward (chapter 12) stays unchanged as the EAV workhorse - loading, saving, attribute backend models. Model\Data\Reward is deliberately something else: a slim data object derived from AbstractExtensibleObject that implements nothing but RewardInterface and serves as its default implementation via a preference. This split is the exact same reason CustomerRepositoryInterface::getById() returns an Api\Data\CustomerInterface object rather than a Magento\Customer\Model\Customer instance (chapter 30) - callers should never accidentally end up depending on EAV-internal methods like _construct() or on attribute backend model side effects.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model\Data;
use Magento\Framework\Api\AbstractExtensibleObject;
use Mironsoft\Loyalty\Api\Data\RewardExtensionInterface;
use Mironsoft\Loyalty\Api\Data\RewardInterface;
/**
* Plain data transfer object for a loyalty reward - decoupled from the EAV
* entity model (Model\Reward, chapter 12) so REST, SOAP and GraphQL always
* serialize this one stable shape instead of AbstractModel internals.
*/
class Reward extends AbstractExtensibleObject implements RewardInterface
{
/**
* @return int|null
*/
public function getRewardId(): ?int
{
$value = $this->_get(self::REWARD_ID);
return $value !== null ? (int) $value : null;
}
/**
* @param int $rewardId Reward entity ID.
* @return $this
*/
public function setRewardId(int $rewardId): self
{
return $this->setData(self::REWARD_ID, $rewardId);
}
/**
* @return string
*/
public function getIdentifier(): string
{
return (string) $this->_get(self::IDENTIFIER);
}
/**
* @param string $identifier Unique reward identifier.
* @return $this
*/
public function setIdentifier(string $identifier): self
{
return $this->setData(self::IDENTIFIER, $identifier);
}
/**
* @return string
*/
public function getTitle(): string
{
return (string) $this->_get(self::TITLE);
}
/**
* @param string $title Reward title.
* @return $this
*/
public function setTitle(string $title): self
{
return $this->setData(self::TITLE, $title);
}
/**
* @return string|null
*/
public function getDescription(): ?string
{
return $this->_get(self::DESCRIPTION);
}
/**
* @param string|null $description Reward description.
* @return $this
*/
public function setDescription(?string $description): self
{
return $this->setData(self::DESCRIPTION, $description);
}
/**
* @return int
*/
public function getPointsCost(): int
{
return (int) $this->_get(self::POINTS_COST);
}
/**
* @param int $pointsCost Points cost.
* @return $this
*/
public function setPointsCost(int $pointsCost): self
{
return $this->setData(self::POINTS_COST, $pointsCost);
}
/**
* @return float|null
*/
public function getDiscountValue(): ?float
{
$value = $this->_get(self::DISCOUNT_VALUE);
return $value !== null ? (float) $value : null;
}
/**
* @param float|null $discountValue Discount value.
* @return $this
*/
public function setDiscountValue(?float $discountValue): self
{
return $this->setData(self::DISCOUNT_VALUE, $discountValue);
}
/**
* @return string
*/
public function getRewardType(): string
{
return (string) $this->_get(self::REWARD_TYPE);
}
/**
* @param string $rewardType One of Model\Reward\Source\RewardType::TYPE_*.
* @return $this
*/
public function setRewardType(string $rewardType): self
{
return $this->setData(self::REWARD_TYPE, $rewardType);
}
/**
* @return bool
*/
public function isActive(): bool
{
return (bool) $this->_get(self::IS_ACTIVE);
}
/**
* @param bool $isActive Active flag.
* @return $this
*/
public function setIsActive(bool $isActive): self
{
return $this->setData(self::IS_ACTIVE, $isActive);
}
/**
* @return RewardExtensionInterface|null
*/
public function getExtensionAttributes(): ?RewardExtensionInterface
{
// @phpstan-ignore-next-line _getExtensionAttributes() is generated at build time
return $this->_getExtensionAttributes();
}
/**
* @param RewardExtensionInterface $extensionAttributes Extension attributes.
* @return $this
*/
public function setExtensionAttributes(RewardExtensionInterface $extensionAttributes): self
{
// @phpstan-ignore-next-line _setExtensionAttributes() is generated at build time
return $this->_setExtensionAttributes($extensionAttributes);
}
}
Search results: RewardSearchResultsInterface
getList() (next, in RewardRepositoryInterface) needs a dedicated return type instead of a bare array, so pagination, sorting, and the total hit count travel along with it - exactly Magento's standard SearchResultsInterface pattern:
interface RewardSearchResultsInterface extends \Magento\Framework\Api\SearchResultsInterface
{
/**
* @return \Mironsoft\Loyalty\Api\Data\RewardInterface[]
*/
public function getItems(): array;
/**
* @param \Mironsoft\Loyalty\Api\Data\RewardInterface[] $items Reward items.
* @return $this
*/
public function setItems(array $items): self;
}The service contract: Api\RewardRepositoryInterface
Six methods, the same CRUD-plus-search shape as every Magento core repository - and the only place chapters 80/81 (REST), 82/83 (GraphQL), and any future SOAP binding will ever touch a reward:
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\RewardInterface;
use Mironsoft\Loyalty\Api\Data\RewardSearchResultsInterface;
/**
* Service contract for reading and writing loyalty rewards - the single
* business-logic entry point REST, SOAP and GraphQL all build on.
*/
interface RewardRepositoryInterface
{
/**
* Loads a reward by its entity id.
*
* @param int $rewardId Reward entity ID.
* @return \Mironsoft\Loyalty\Api\Data\RewardInterface
* @throws NoSuchEntityException
*/
public function getById(int $rewardId): RewardInterface;
/**
* Loads a reward by its identifier (chapter 46's storefront route parameter).
*
* @param string $identifier Unique reward identifier.
* @return \Mironsoft\Loyalty\Api\Data\RewardInterface
* @throws NoSuchEntityException
*/
public function getByIdentifier(string $identifier): RewardInterface;
/**
* Loads a filtered, sorted, paginated list of rewards.
*
* @param SearchCriteriaInterface $searchCriteria Search criteria.
* @return \Mironsoft\Loyalty\Api\Data\RewardSearchResultsInterface
*/
public function getList(SearchCriteriaInterface $searchCriteria): RewardSearchResultsInterface;
/**
* Saves a reward, creating it if it has no entity id yet.
*
* @param \Mironsoft\Loyalty\Api\Data\RewardInterface $reward Reward to save.
* @return \Mironsoft\Loyalty\Api\Data\RewardInterface
* @throws CouldNotSaveException
*/
public function save(RewardInterface $reward): RewardInterface;
/**
* Deletes a reward.
*
* @param \Mironsoft\Loyalty\Api\Data\RewardInterface $reward Reward to delete.
* @return bool
* @throws CouldNotDeleteException
*/
public function delete(RewardInterface $reward): bool;
/**
* Deletes a reward by its entity id.
*
* @param int $rewardId Reward entity ID.
* @return bool
* @throws NoSuchEntityException
* @throws CouldNotDeleteException
*/
public function deleteById(int $rewardId): bool;
}
The implementation: Model\RewardRepository
The implementation translates in both directions: toDataObject() builds a Data\Reward from a loaded EAV model, while save() copies the fields of an incoming Data\Reward back onto an EAV model before ResourceModel\Reward::save() (chapter 12) actually writes it.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\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 Mironsoft\Loyalty\Api\Data\RewardInterface;
use Mironsoft\Loyalty\Api\Data\RewardInterfaceFactory;
use Mironsoft\Loyalty\Api\Data\RewardSearchResultsInterface;
use Mironsoft\Loyalty\Api\Data\RewardSearchResultsInterfaceFactory;
use Mironsoft\Loyalty\Api\RewardRepositoryInterface;
use Mironsoft\Loyalty\Model\ResourceModel\Reward as RewardResource;
use Mironsoft\Loyalty\Model\ResourceModel\Reward\CollectionFactory as RewardCollectionFactory;
/**
* Repository implementation backing RewardRepositoryInterface, mapping
* between the EAV entity model (Reward, chapter 12) and the plain
* Data\Reward DTO every API surface actually talks to.
*/
class RewardRepository implements RewardRepositoryInterface
{
/**
* @param RewardResource $resource EAV resource model for Reward.
* @param RewardFactory $rewardFactory Factory for the EAV entity model.
* @param RewardInterfaceFactory $rewardDataFactory Factory for the Data\Reward DTO.
* @param RewardCollectionFactory $collectionFactory Factory for the EAV reward collection.
* @param RewardSearchResultsInterfaceFactory $searchResultsFactory Factory for search results.
* @param CollectionProcessorInterface $collectionProcessor Applies filters/sorting/pagination.
*/
public function __construct(
private readonly RewardResource $resource,
private readonly RewardFactory $rewardFactory,
private readonly RewardInterfaceFactory $rewardDataFactory,
private readonly RewardCollectionFactory $collectionFactory,
private readonly RewardSearchResultsInterfaceFactory $searchResultsFactory,
private readonly CollectionProcessorInterface $collectionProcessor,
) {
}
/**
* @inheritDoc
*/
public function getById(int $rewardId): RewardInterface
{
return $this->toDataObject($this->loadModel($rewardId));
}
/**
* @inheritDoc
*/
public function getByIdentifier(string $identifier): RewardInterface
{
$collection = $this->collectionFactory->create();
$collection->addFieldToFilter('identifier', ['eq' => $identifier])->setPageSize(1);
/** @var Reward $reward */
$reward = $collection->getFirstItem();
if (!$reward->getId()) {
throw new NoSuchEntityException(
__('Reward with identifier "%1" does not exist.', $identifier)
);
}
return $this->toDataObject($reward);
}
/**
* @inheritDoc
*/
public function getList(SearchCriteriaInterface $searchCriteria): RewardSearchResultsInterface
{
$collection = $this->collectionFactory->create();
$this->collectionProcessor->process($searchCriteria, $collection);
$items = [];
foreach ($collection as $reward) {
$items[] = $this->toDataObject($reward);
}
$searchResults = $this->searchResultsFactory->create();
$searchResults->setSearchCriteria($searchCriteria);
$searchResults->setItems($items);
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
/**
* @inheritDoc
*/
public function save(RewardInterface $reward): RewardInterface
{
$rewardModel = $reward->getRewardId()
? $this->loadModel((int) $reward->getRewardId())
: $this->rewardFactory->create();
$rewardModel->addData([
RewardInterface::IDENTIFIER => $reward->getIdentifier(),
RewardInterface::TITLE => $reward->getTitle(),
RewardInterface::DESCRIPTION => $reward->getDescription(),
RewardInterface::POINTS_COST => $reward->getPointsCost(),
RewardInterface::DISCOUNT_VALUE => $reward->getDiscountValue(),
RewardInterface::REWARD_TYPE => $reward->getRewardType(),
RewardInterface::IS_ACTIVE => $reward->isActive() ? 1 : 0,
]);
try {
$this->resource->save($rewardModel);
} catch (\Exception $exception) {
throw new CouldNotSaveException(
__('Could not save the reward: %1', $exception->getMessage()),
$exception
);
}
return $this->toDataObject($rewardModel);
}
/**
* @inheritDoc
*/
public function delete(RewardInterface $reward): bool
{
return $this->deleteById((int) $reward->getRewardId());
}
/**
* @inheritDoc
*/
public function deleteById(int $rewardId): bool
{
$rewardModel = $this->loadModel($rewardId);
try {
$this->resource->delete($rewardModel);
} catch (\Exception $exception) {
throw new CouldNotDeleteException(
__('Could not delete the reward: %1', $exception->getMessage()),
$exception
);
}
return true;
}
/**
* Loads the EAV entity model by id, or throws if it doesn't exist.
*
* @param int $rewardId Reward entity ID.
* @return Reward
* @throws NoSuchEntityException
*/
private function loadModel(int $rewardId): Reward
{
/** @var Reward $reward */
$reward = $this->rewardFactory->create();
$this->resource->load($reward, $rewardId);
if (!$reward->getId()) {
throw new NoSuchEntityException(
__('Reward with id "%1" does not exist.', $rewardId)
);
}
return $reward;
}
/**
* Maps the EAV entity model onto the plain Data\Reward DTO.
*
* @param Reward $reward EAV entity model.
* @return RewardInterface
*/
private function toDataObject(Reward $reward): RewardInterface
{
$rewardData = $this->rewardDataFactory->create();
$rewardData->setRewardId((int) $reward->getId());
$rewardData->setIdentifier((string) $reward->getData('identifier'));
$rewardData->setTitle((string) $reward->getData('title'));
$rewardData->setDescription($reward->getData('description'));
$rewardData->setPointsCost((int) $reward->getData('points_cost'));
$discountValue = $reward->getData('discount_value');
$rewardData->setDiscountValue($discountValue !== null ? (float) $discountValue : null);
$rewardData->setRewardType((string) $reward->getData('reward_type'));
$rewardData->setIsActive((bool) $reward->getData('is_active'));
return $rewardData;
}
}
Tipp: getList() passes the SearchCriteria straight through to the default CollectionProcessor, which internally just calls $collection->addFieldToFilter($field, $condition). That only works here because \Magento\Eav\Model\Entity\Collection\AbstractCollection (the base class of the chapter 15 collection) redirects addFieldToFilter() internally to addAttributeToFilter(). Filter field names in a REST request's SearchCriteria must therefore match EAV attribute codes exactly (title, points_cost, reward_type, is_active), not column names from a flat table.
di.xml: three new preferences
<?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="Mironsoft\Loyalty\Api\Data\RewardInterface"
type="Mironsoft\Loyalty\Model\Data\Reward"/>
<preference for="Mironsoft\Loyalty\Api\Data\RewardSearchResultsInterface"
type="Magento\Framework\Api\SearchResults"/>
<preference for="Mironsoft\Loyalty\Api\RewardRepositoryInterface"
type="Mironsoft\Loyalty\Model\RewardRepository"/>
</config>
SOAP comes along for free
The chapter title deliberately mentions SOAP too: once RewardRepositoryInterface is registered as a REST route via webapi.xml in chapter 80, Magento automatically generates a WSDL from the very same service contract at /soap/{store}?wsdl&services=mironsoftLoyaltyRewardRepositoryV1 - without a single extra line of code. This series doesn't go deeper into SOAP (REST/GraphQL dominate in practice), but that fact is exactly this chapter's point: service contracts are the foundation, REST/SOAP/GraphQL are interchangeable transport layers on top.
With the repository and data interface in place, chapter 80 registers this series' first custom REST route - for now still for the simpler points balance, not for rewards themselves.