Service Contracts als Basis für REST, SOAP und GraphQL
Service Contracts als Basis für REST, SOAP und GraphQL: Api\Data\RewardInterface und RewardRepositoryInterface
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Block 9 hat einen kompletten neuen Produkttyp gebaut, ohne auch nur eine Zeile aus Block 1 anzufassen - Service Contracts wie PointsLedgerRepositoryInterface und CustomerRepositoryInterface waren einfach schon da und wiederverwendbar. Block 10 macht diese Idee zum Hauptthema: Bevor irgendein REST-, SOAP- oder GraphQL-Endpunkt entsteht, braucht die Prämien-Entity aus Block 2 endlich genau dieselbe Schicht, die der Punkte-Ledger seit Kapitel 6 schon hat.
Warum Block 2 diese Klassen bewusst ausgelassen hat
Kapitel 10-18 haben Model\Reward, ResourceModel\Reward und ResourceModel\Reward\Collection gebaut - genug für Admin-Grid (Kapitel 16) und Storefront-ViewModels (Kapitel 49), die alle innerhalb desselben PHP-Prozesses laufen und direkt auf das EAV-Model zugreifen dürfen. Sobald aber ein externer Client - ein mobiles Frontend, ein Partnersystem, dieselbe GraphQL-Anfrage, die Block 10 gleich baut - auf eine Prämie zugreifen soll, reicht das nicht mehr: Magento\Framework\Model\AbstractModel ist niemals ein serialisierbarer Vertrag, und getData()/setData() kennen weder feste Feldnamen noch einen stabilen Typ. Genau dafür existiert das Api/Api\Data-Namespace-Paar, das PointsLedgerInterface (Kapitel 6) bereits vorgemacht hat - dieses Kapitel überträgt dasselbe Muster jetzt auf Rewards.
Das Data-Interface: Api\Data\RewardInterface
Anders als PointsLedgerInterface erweitert RewardInterface bewusst ExtensibleDataInterface - dieselbe Basis, die auch Magentos eigene ProductInterface/CategoryInterface nutzen. Der Grund: Rewards sind eine öffentlich erweiterbare Entity (jedes Drittmodul kann später per eigenem extension_attributes.xml zusätzliche Felder andocken, siehe Kapitel 85), während der Ledger-Eintrag aus Kapitel 6 ein rein internes, unveränderliches Log-Objekt bleibt, das nie erweitert werden soll.
<?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 allein reicht nicht - Magentos Codegenerator baut die passende RewardExtensionInterface nur, wenn eine (zunächst leere) extension_attributes.xml die Entity dafür registriert:
<?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>
Das Rückgabeobjekt: Model\Data\Reward statt Model\Reward direkt
Model\Reward (Kapitel 12) bleibt unverändert die EAV-Arbeitspflicht - Laden, Speichern, Attribut-Backend-Modelle. Model\Data\Reward ist etwas bewusst anderes: ein schlankes, von AbstractExtensibleObject abgeleitetes Datenobjekt, das ausschließlich RewardInterface implementiert und via Preference als dessen Standardimplementierung dient. Diese Trennung ist derselbe Grund, aus dem CustomerRepositoryInterface::getById() ein Api\Data\CustomerInterface-Objekt liefert statt einer Magento\Customer\Model\Customer-Instanz (Kapitel 30) - der Aufrufer soll niemals versehentlich an EAV-internen Methoden wie _construct() oder an Backend-Modell-Nebenwirkungen hängen bleiben.
<?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);
}
}
Suchergebnisse: RewardSearchResultsInterface
getList() (gleich in RewardRepositoryInterface) braucht einen eigenen Rückgabetyp statt eines nackten Arrays, damit Paginierung, Sortierung und die Gesamtzahl der Treffer mitreisen - exakt Magentos Standard-SearchResultsInterface-Muster:
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;
}Der Service Contract: Api\RewardRepositoryInterface
Sechs Methoden, dieselbe CRUD-plus-Suche-Form wie jedes Magento-Core-Repository - und der einzige Ort, an dem Kapitel 80/81 (REST), 82/83 (GraphQL) und jedes künftige SOAP-Binding jemals mit einer Prämie interagieren:
<?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;
}
Die Implementierung: Model\RewardRepository
Die Implementierung übersetzt in beide Richtungen: toDataObject() baut aus einem geladenen EAV-Model ein Data\Reward, save() kopiert umgekehrt die Felder eines übergebenen Data\Reward zurück auf ein EAV-Model, bevor ResourceModel\Reward::save() (Kapitel 12) tatsächlich schreibt.
<?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() übergibt die SearchCriteria unverändert an den Standard-CollectionProcessor - der ruft intern stur $collection->addFieldToFilter($field, $condition) auf. Das funktioniert hier nur, weil \Magento\Eav\Model\Entity\Collection\AbstractCollection (Basisklasse der Kapitel-15-Collection) addFieldToFilter() intern auf addAttributeToFilter() umleitet. Filter-Feldnamen im SearchCriteria einer REST-Anfrage müssen deshalb exakt den EAV-Attribut-Codes entsprechen (title, points_cost, reward_type, is_active), nicht Spaltennamen einer flachen Tabelle.
di.xml: drei neue 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 kommt automatisch mit
Der Kapiteltitel nennt bewusst auch SOAP: Sobald RewardRepositoryInterface in Kapitel 80 über webapi.xml als REST-Route registriert ist, generiert Magento aus demselben Service Contract automatisch ein WSDL unter /soap/{store}?wsdl&services=mironsoftLoyaltyRewardRepositoryV1 - ohne eine einzige zusätzliche Zeile Code. Diese Serie vertieft SOAP nicht weiter (in der Praxis dominiert REST/GraphQL), aber der Fakt selbst ist der eigentliche Punkt dieses Kapitels: Service Contracts sind das Fundament, REST/SOAP/GraphQL sind austauschbare Transportschichten darüber.
Mit Repository und Data-Interface fertig, registriert Kapitel 80 die erste eigene REST-Route dieser Serie - fürs Erste noch für den einfacheren Punktestand, nicht für Rewards selbst.