The Repository Pattern: PointsLedgerRepositoryInterface and Its Implementation
The Repository Pattern: PointsLedgerRepositoryInterface and Its Implementation
~8 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
So far, nothing from outside touches PointsLedger - exactly right, because CLAUDE.md calls for service contracts instead of direct resource model access from controllers, observers, or API endpoints. This chapter delivers the missing layer: an Api\Data interface for the ledger entry itself and a PointsLedgerRepositoryInterface for loading and saving - swappable for any other implementation without callers noticing.
Api\Data\PointsLedgerInterface
The data interface defines getters/setters for every column plus constants for field names and the four valid type values - the latter get referenced repeatedly starting in chapter 9, instead of scattering "earn"/"redeem" as magic strings across the code.
<?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 implements the interface
The model from chapter 4 is extended with implements PointsLedgerInterface and all getters/setters. AbstractModel::getData()/setData() do the actual work - the methods here provide type safety toward the outside.
<?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: The preference for PointsLedgerInterface lets Magento automatically generate a PointsLedgerInterfaceFactory, which chapter 9 uses directly to create new ledger entries - without hand-writing a dedicated factory class at all.
Achtung: save() on the repository is deliberately not called "update" or "persist changes" - this repository is built for an append-only ledger. Loading an already-saved row, changing its points, and saving it again falsifies the history. Corrections always go through a new row of type adjust - exactly what chapter 9 shows with the console command.
With the repository and service contract in place, the complete data access layer exists. Chapter 7 turns to configuration - the values that actually feed PointsCalculator and the future observers with meaningful numbers.