Magento 2 Experten — Hyvä Theme, Tailwind CSS & SEO aus einer Hand ›

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.

app/code/Mironsoft/Loyalty/Api/Data/RewardInterface.php
<?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.

app/code/Mironsoft/Loyalty/Model/Data/Reward.php
<?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:

app/code/Mironsoft/Loyalty/Api/RewardRepositoryInterface.php
<?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.

app/code/Mironsoft/Loyalty/Model/RewardRepository.php
<?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

app/code/Mironsoft/Loyalty/etc/di.xml (ergänzt)
<?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.