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

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.

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

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

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

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

app/code/Mironsoft/Loyalty/etc/di.xml
<?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.