Repository-Pattern: PointsLedgerRepositoryInterface und Implementierung
Repository-Pattern: PointsLedgerRepositoryInterface und Implementierung
~8 Min. Lesezeit Zuletzt aktualisiert am 9. August 2026
Bisher greift nichts von außen auf PointsLedger zu - genau richtig, denn CLAUDE.md verlangt Service Contracts statt direktem ResourceModel-Zugriff aus Controllern, Observern oder API-Endpunkten. Dieses Kapitel liefert die fehlende Schicht: ein Api\Data-Interface für den Ledger-Eintrag selbst und ein PointsLedgerRepositoryInterface für Laden und Speichern - austauschbar gegen jede andere Implementierung, ohne dass Aufrufer etwas davon merken.
Api\Data\PointsLedgerInterface
Das Data-Interface definiert Getter/Setter für jede Spalte plus Konstanten für Feldnamen und die vier gültigen type-Werte - letztere werden ab Kapitel 9 mehrfach referenziert, statt "earn"/"redeem" als magische Zeichenketten im Code zu verstreuen.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api\Data;
/**
* Data interface for a single, immutable points ledger entry.
*/
interface PointsLedgerInterface
{
public const LEDGER_ID = 'ledger_id';
public const CUSTOMER_ID = 'customer_id';
public const ORDER_ID = 'order_id';
public const POINTS = 'points';
public const TYPE = 'type';
public const BALANCE_AFTER = 'balance_after';
public const CREATED_AT = 'created_at';
public const EXPIRES_AT = 'expires_at';
public const TYPE_EARN = 'earn';
public const TYPE_REDEEM = 'redeem';
public const TYPE_EXPIRE = 'expire';
public const TYPE_ADJUST = 'adjust';
/**
* @return int|null
*/
public function getLedgerId(): ?int;
/**
* @return int
*/
public function getCustomerId(): int;
/**
* @param int $customerId Customer entity ID.
* @return $this
*/
public function setCustomerId(int $customerId): self;
/**
* @return int|null
*/
public function getOrderId(): ?int;
/**
* @param int|null $orderId Order entity ID, or null for entries with no order reference.
* @return $this
*/
public function setOrderId(?int $orderId): self;
/**
* @return int
*/
public function getPoints(): int;
/**
* @param int $points Positive for a credit, negative for a debit.
* @return $this
*/
public function setPoints(int $points): self;
/**
* @return string
*/
public function getType(): string;
/**
* @param string $type One of TYPE_EARN, TYPE_REDEEM, TYPE_EXPIRE, TYPE_ADJUST.
* @return $this
*/
public function setType(string $type): self;
/**
* @return int
*/
public function getBalanceAfter(): int;
/**
* @param int $balanceAfter Customer balance immediately after this entry.
* @return $this
*/
public function setBalanceAfter(int $balanceAfter): self;
/**
* @return string|null
*/
public function getCreatedAt(): ?string;
/**
* @return string|null
*/
public function getExpiresAt(): ?string;
/**
* @param string|null $expiresAt Expiry timestamp, or null for entries that never expire.
* @return $this
*/
public function setExpiresAt(?string $expiresAt): self;
}PointsLedger implementiert das Interface
Das Model aus Kapitel 4 wird um implements PointsLedgerInterface und alle Getter/Setter erweitert. AbstractModel::getData()/setData() übernehmen die eigentliche Arbeit - die Methoden hier sorgen für Typsicherheit nach außen.
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model;
use Magento\Framework\Model\AbstractModel;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger as PointsLedgerResource;
/**
* Points ledger entity model, represents a single, immutable ledger entry.
*/
class PointsLedger extends AbstractModel implements PointsLedgerInterface
{
/**
* Binds the model to its resource model.
*
* @return void
*/
protected function _construct(): void
{
$this->_init(PointsLedgerResource::class);
}
/**
* @return int|null
*/
public function getLedgerId(): ?int
{
$value = $this->getData(self::LEDGER_ID);
return $value !== null ? (int) $value : null;
}
/**
* @return int
*/
public function getCustomerId(): int
{
return (int) $this->getData(self::CUSTOMER_ID);
}
/**
* @param int $customerId Customer entity ID.
* @return $this
*/
public function setCustomerId(int $customerId): self
{
return $this->setData(self::CUSTOMER_ID, $customerId);
}
/**
* @return int|null
*/
public function getOrderId(): ?int
{
$value = $this->getData(self::ORDER_ID);
return $value !== null ? (int) $value : null;
}
/**
* @param int|null $orderId Order entity ID, or null for entries with no order reference.
* @return $this
*/
public function setOrderId(?int $orderId): self
{
return $this->setData(self::ORDER_ID, $orderId);
}
/**
* @return int
*/
public function getPoints(): int
{
return (int) $this->getData(self::POINTS);
}
/**
* @param int $points Positive for a credit, negative for a debit.
* @return $this
*/
public function setPoints(int $points): self
{
return $this->setData(self::POINTS, $points);
}
/**
* @return string
*/
public function getType(): string
{
return (string) $this->getData(self::TYPE);
}
/**
* @param string $type One of TYPE_EARN, TYPE_REDEEM, TYPE_EXPIRE, TYPE_ADJUST.
* @return $this
*/
public function setType(string $type): self
{
return $this->setData(self::TYPE, $type);
}
/**
* @return int
*/
public function getBalanceAfter(): int
{
return (int) $this->getData(self::BALANCE_AFTER);
}
/**
* @param int $balanceAfter Customer balance immediately after this entry.
* @return $this
*/
public function setBalanceAfter(int $balanceAfter): self
{
return $this->setData(self::BALANCE_AFTER, $balanceAfter);
}
/**
* @return string|null
*/
public function getCreatedAt(): ?string
{
$value = $this->getData(self::CREATED_AT);
return $value !== null ? (string) $value : null;
}
/**
* @return string|null
*/
public function getExpiresAt(): ?string
{
$value = $this->getData(self::EXPIRES_AT);
return $value !== null ? (string) $value : null;
}
/**
* @param string|null $expiresAt Expiry timestamp, or null for entries that never expire.
* @return $this
*/
public function setExpiresAt(?string $expiresAt): self
{
return $this->setData(self::EXPIRES_AT, $expiresAt);
}
}Api\PointsLedgerRepositoryInterface
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Api;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
/**
* Service contract for reading and writing points ledger entries.
*/
interface PointsLedgerRepositoryInterface
{
/**
* Loads a ledger entry by its ID.
*
* @param int $ledgerId Primary key of the ledger entry.
* @return PointsLedgerInterface
* @throws NoSuchEntityException
*/
public function getById(int $ledgerId): PointsLedgerInterface;
/**
* Persists a ledger entry. Ledger entries are append-only - callers must never
* load an existing entry and save it again with changed points/balance_after.
*
* @param PointsLedgerInterface $ledgerEntry Entry to persist.
* @return PointsLedgerInterface
* @throws CouldNotSaveException
*/
public function save(PointsLedgerInterface $ledgerEntry): PointsLedgerInterface;
/**
* Loads all ledger entries of a customer, most recent first.
*
* @param int $customerId Customer entity ID.
* @return PointsLedgerInterface[]
*/
public function getListByCustomerId(int $customerId): array;
}Model\PointsLedgerRepository
<?php
declare(strict_types=1);
namespace Mironsoft\Loyalty\Model;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Loyalty\Api\Data\PointsLedgerInterface;
use Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger as PointsLedgerResource;
use Mironsoft\Loyalty\Model\ResourceModel\PointsLedger\CollectionFactory;
/**
* Persists and loads points ledger entries through the resource model.
*/
class PointsLedgerRepository implements PointsLedgerRepositoryInterface
{
/**
* @param PointsLedgerResource $resource Resource model for load/save.
* @param PointsLedgerFactory $pointsLedgerFactory Factory for the ledger entry model.
* @param CollectionFactory $collectionFactory Factory for the ledger entry collection.
*/
public function __construct(
private readonly PointsLedgerResource $resource,
private readonly PointsLedgerFactory $pointsLedgerFactory,
private readonly CollectionFactory $collectionFactory,
) {
}
/**
* @param int $ledgerId Primary key of the ledger entry.
* @return PointsLedgerInterface
* @throws NoSuchEntityException
*/
public function getById(int $ledgerId): PointsLedgerInterface
{
$ledgerEntry = $this->pointsLedgerFactory->create();
$this->resource->load($ledgerEntry, $ledgerId);
if (!$ledgerEntry->getLedgerId()) {
throw new NoSuchEntityException(
__('Points ledger entry with ID "%1" does not exist.', $ledgerId)
);
}
return $ledgerEntry;
}
/**
* @param PointsLedgerInterface $ledgerEntry Entry to persist.
* @return PointsLedgerInterface
* @throws CouldNotSaveException
*/
public function save(PointsLedgerInterface $ledgerEntry): PointsLedgerInterface
{
try {
/** @var PointsLedger $ledgerEntry */
$this->resource->save($ledgerEntry);
} catch (\Exception $exception) {
throw new CouldNotSaveException(
__('Could not save the points ledger entry.'),
$exception
);
}
return $ledgerEntry;
}
/**
* @param int $customerId Customer entity ID.
* @return PointsLedgerInterface[]
*/
public function getListByCustomerId(int $customerId): array
{
$collection = $this->collectionFactory->create();
$collection->addCustomerFilter($customerId);
$collection->addNewestFirstOrder();
return array_values($collection->getItems());
}
}di.xml: 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\PointsLedgerInterface"
type="Mironsoft\Loyalty\Model\PointsLedger"/>
<preference for="Mironsoft\Loyalty\Api\PointsLedgerRepositoryInterface"
type="Mironsoft\Loyalty\Model\PointsLedgerRepository"/>
</config>Tipp: Die Preference für PointsLedgerInterface erlaubt es Magento, automatisch eine PointsLedgerInterfaceFactory zu generieren, die Kapitel 9 direkt nutzt, um neue Ledger-Einträge zu erzeugen - ganz ohne eine eigene Factory-Klasse von Hand zu schreiben.
Achtung: save() im Repository heißt bewusst nicht "update" oder "persist changes" - dieses Repository ist für ein Append-Only-Ledger gedacht. Wer eine bereits gespeicherte Zeile lädt, ihre points ändert und erneut speichert, verfälscht die Historie. Korrekturen laufen immer über eine neue Zeile vom Typ adjust - genau das zeigt Kapitel 9 am Konsolenbefehl.
Mit Repository und Service Contract steht die komplette Datenzugriffsschicht. Kapitel 7 wendet sich der Konfiguration zu - den Werten, die PointsCalculator und die zukünftigen Observer überhaupt erst mit sinnvollen Zahlen füttern.