Repository Pattern in Magento 2: Service Contracts, SearchCriteria und eigene Repositories entwickeln
· Lesezeit: ca. 15 Minuten · Teil der Serie: Design Patterns in Magento 2
Repository Pattern
in Magento 2
Service Contracts, Data Interfaces, SearchCriteriaBuilder und REST API – eigene Repositories entwickeln, die stabile APIs über Upgrades hinweg garantieren.
Das Repository Pattern: Datenzugriff hinter einer stabilen API
Das Repository Pattern ist eine Abstraktionsschicht zwischen der Geschäftslogik und der Datenpersistenzschicht. Es kapselt alle Datenbankoperationen hinter einem Interface und sorgt dafür, dass der aufrufende Code nichts über die verwendete Datenbanktechnologie, das ORM oder die Tabellen-Struktur wissen muss.
In Magento 2 sind Repositories ein integraler Bestandteil der Service Contracts – dem Konzept stabiler, versionierter APIs zwischen Modulen. Ein Service Contract besteht aus zwei Teilen:
- Data Interfaces (
Api/Data/): Definieren die Datenstruktur eines Objekts (z.B. ein Blog-Post). Keine Logik, nur Getter/Setter. - Service Interfaces (
Api/): Definieren die Operationen auf diesen Daten (CRUD, Suche). Das ist das Repository.
- 1. Verzeichnisstruktur eines vollständigen Service Contracts
- 2. Data Interface: die Datenstruktur definieren
- 3. Repository Interface: die Service-API
- 4. Model, Resource Model und Collection
- 5. Repository Implementierung
- 6. SearchCriteria: flexibles Suchen
- 7. di.xml und webapi.xml
- 8. REST API Exposition
- 9. Zusammenfassung
- 10. FAQ
1. Verzeichnisstruktur eines vollständigen Service Contracts
app/code/Mironsoft/Blog/
├── Api/
│ ├── Data/
│ │ ├── PostInterface.php ← Data Interface
│ │ └── PostSearchResultsInterface.php
│ └── PostRepositoryInterface.php ← Service Interface (Repository)
│
├── Model/
│ ├── Post.php ← Model + Data Interface Implementierung
│ ├── PostRepository.php ← Repository Implementierung
│ └── ResourceModel/
│ ├── Post.php ← Resource Model (DB-Zugriff)
│ └── Post/
│ └── Collection.php ← Collection
│
├── etc/
│ ├── di.xml ← Preference + Factory Binding
│ └── webapi.xml ← REST API Routing
│
└── registration.php
2. Data Interface: die Datenstruktur definieren
Das Data Interface definiert die Eigenschaften eines Datenobjekts mit Getter- und Setter-Methoden. Es liegt unter Api/Data/ und garantiert, dass alle Konsumenten dieselbe Datenstruktur erwarten können.
<?php
declare(strict_types=1);
// Api/Data/PostInterface.php
namespace Mironsoft\Blog\Api\Data;
/**
* Blog post data interface — part of the Service Contract.
* Defines the stable data structure for a blog post.
*/
interface PostInterface
{
// Constants for field names — used in EAV and collection filters
public const POST_ID = 'post_id';
public const TITLE = 'title';
public const CONTENT = 'content';
public const STATUS = 'status';
public const AUTHOR_ID = 'author_id';
public const PUBLISHED_AT = 'published_at';
public const CREATED_AT = 'created_at';
/** Returns the post ID. */
public function getId(): ?int;
/** Returns the post title. */
public function getTitle(): string;
/** Sets the post title. */
public function setTitle(string $title): self;
/** Returns the post content (HTML). */
public function getContent(): string;
/** Sets the post content. */
public function setContent(string $content): self;
/** Returns the post status (draft, published, archived). */
public function getStatus(): string;
/** Sets the post status. */
public function setStatus(string $status): self;
/** Returns the author's customer ID. */
public function getAuthorId(): int;
/** Sets the author's customer ID. */
public function setAuthorId(int $authorId): self;
/** Returns the publication timestamp. */
public function getPublishedAt(): ?string;
/** Sets the publication timestamp. */
public function setPublishedAt(string $publishedAt): self;
}
3. Repository Interface: die Service-API definieren
<?php
declare(strict_types=1);
// Api/PostRepositoryInterface.php
namespace Mironsoft\Blog\Api;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Api\Data\PostSearchResultsInterface;
/**
* Blog post repository interface — Service Contract API.
*
* This interface guarantees backwards compatibility across Magento upgrades.
* Consumers must inject this interface, never the concrete implementation.
*/
interface PostRepositoryInterface
{
/**
* Retrieves a post by its ID.
*
* @throws NoSuchEntityException if post does not exist
*/
public function getById(int $postId): PostInterface;
/**
* Saves a post (create or update).
*
* @throws CouldNotSaveException if saving fails
*/
public function save(PostInterface $post): PostInterface;
/**
* Deletes a post.
*
* @throws CouldNotDeleteException if deletion fails
*/
public function delete(PostInterface $post): bool;
/**
* Deletes a post by its ID.
*
* @throws NoSuchEntityException if post does not exist
* @throws CouldNotDeleteException if deletion fails
*/
public function deleteById(int $postId): bool;
/**
* Returns a list of posts matching the given search criteria.
*/
public function getList(SearchCriteriaInterface $searchCriteria): PostSearchResultsInterface;
}
4. Model, Resource Model und Collection
Das Model implementiert das Data Interface und erbt von Magento's AbstractModel. Der Resource Model übernimmt den eigentlichen Datenbankzugriff.
<?php
declare(strict_types=1);
// Model/Post.php — implements the Data Interface
namespace Mironsoft\Blog\Model;
use Magento\Framework\Model\AbstractModel;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Model\ResourceModel\Post as PostResource;
class Post extends AbstractModel implements PostInterface
{
protected $_eventPrefix = 'mironsoft_blog_post';
protected function _construct(): void
{
$this->_init(PostResource::class);
}
public function getId(): ?int
{
return $this->getData(self::POST_ID) ? (int) $this->getData(self::POST_ID) : null;
}
public function getTitle(): string
{
return (string) $this->getData(self::TITLE);
}
public function setTitle(string $title): self
{
return $this->setData(self::TITLE, $title);
}
public function getContent(): string
{
return (string) $this->getData(self::CONTENT);
}
public function setContent(string $content): self
{
return $this->setData(self::CONTENT, $content);
}
public function getStatus(): string
{
return (string) $this->getData(self::STATUS);
}
public function setStatus(string $status): self
{
return $this->setData(self::STATUS, $status);
}
public function getAuthorId(): int
{
return (int) $this->getData(self::AUTHOR_ID);
}
public function setAuthorId(int $authorId): self
{
return $this->setData(self::AUTHOR_ID, $authorId);
}
public function getPublishedAt(): ?string
{
return $this->getData(self::PUBLISHED_AT);
}
public function setPublishedAt(string $publishedAt): self
{
return $this->setData(self::PUBLISHED_AT, $publishedAt);
}
}
<?php
declare(strict_types=1);
// Model/ResourceModel/Post.php — handles actual DB read/write
namespace Mironsoft\Blog\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Post extends AbstractDb
{
protected function _construct(): void
{
// 'mironsoft_blog_post' = table name, 'post_id' = primary key
$this->_init('mironsoft_blog_post', 'post_id');
}
}
5. Repository Implementierung
Die Repository-Implementierung kapselt alle Datenbankoperationen, behandelt Exceptions und enthält einen einfachen In-Memory-Cache für wiederholte Zugriffe auf dasselbe Objekt.
<?php
declare(strict_types=1);
// Model/PostRepository.php
namespace Mironsoft\Blog\Model;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Api\Data\PostInterfaceFactory;
use Mironsoft\Blog\Api\Data\PostSearchResultsInterface;
use Mironsoft\Blog\Api\Data\PostSearchResultsInterfaceFactory;
use Mironsoft\Blog\Api\PostRepositoryInterface;
use Mironsoft\Blog\Model\ResourceModel\Post as PostResource;
use Mironsoft\Blog\Model\ResourceModel\Post\CollectionFactory;
class PostRepository implements PostRepositoryInterface
{
/** In-memory cache: avoids loading same post twice in one request */
private array $cache = [];
public function __construct(
private readonly PostResource $resource,
private readonly PostInterfaceFactory $postFactory,
private readonly CollectionFactory $collectionFactory,
private readonly PostSearchResultsInterfaceFactory $searchResultsFactory,
private readonly CollectionProcessorInterface $collectionProcessor
) {}
public function getById(int $postId): PostInterface
{
if (!isset($this->cache[$postId])) {
/** @var PostInterface $post */
$post = $this->postFactory->create();
$this->resource->load($post, $postId);
if (!$post->getId()) {
throw new NoSuchEntityException(
__('Blog post with ID "%1" does not exist.', $postId)
);
}
$this->cache[$postId] = $post;
}
return $this->cache[$postId];
}
public function save(PostInterface $post): PostInterface
{
try {
$this->resource->save($post);
// Invalidate cache for updated entity
unset($this->cache[$post->getId()]);
} catch (\Exception $e) {
throw new CouldNotSaveException(
__('Could not save blog post: %1', $e->getMessage()),
$e
);
}
return $post;
}
public function delete(PostInterface $post): bool
{
try {
unset($this->cache[$post->getId()]);
$this->resource->delete($post);
} catch (\Exception $e) {
throw new CouldNotDeleteException(
__('Could not delete blog post: %1', $e->getMessage()),
$e
);
}
return true;
}
public function deleteById(int $postId): bool
{
return $this->delete($this->getById($postId));
}
public function getList(SearchCriteriaInterface $searchCriteria): PostSearchResultsInterface
{
$collection = $this->collectionFactory->create();
// CollectionProcessor applies filters, sort orders, pagination from SearchCriteria
$this->collectionProcessor->process($searchCriteria, $collection);
/** @var PostSearchResultsInterface $searchResults */
$searchResults = $this->searchResultsFactory->create();
$searchResults->setSearchCriteria($searchCriteria);
$searchResults->setItems($collection->getItems());
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
}
6. SearchCriteria: flexibles und typsicheres Suchen
Der SearchCriteriaBuilder ist der Builder für Datenbankabfragen über Repositories. Er erlaubt Filter, Sortierung und Pagination auf eine deklarative, typsichere Weise – ohne SQL zu schreiben.
<?php
declare(strict_types=1);
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SortOrderBuilder;
use Magento\Framework\Api\FilterBuilder;
use Mironsoft\Blog\Api\PostRepositoryInterface;
class BlogService
{
public function __construct(
private readonly PostRepositoryInterface $postRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
private readonly SortOrderBuilder $sortOrderBuilder,
private readonly FilterBuilder $filterBuilder
) {}
/**
* Returns published posts with pagination and sorting.
*/
public function getPublishedPosts(int $page = 1, int $pageSize = 10): array
{
// Build sort order: newest first
$sortOrder = $this->sortOrderBuilder
->setField('published_at')
->setDirection('DESC')
->create();
// Build search criteria
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('status', 'published')
->addSortOrder($sortOrder)
->setCurrentPage($page)
->setPageSize($pageSize)
->create();
$results = $this->postRepository->getList($searchCriteria);
return $results->getItems();
}
/**
* Full-text search: posts by author OR title keyword.
* Demonstrates OR-filter groups.
*/
public function searchPosts(string $keyword, int $authorId): array
{
// Filter group 1: title contains keyword
$titleFilter = $this->filterBuilder
->setField('title')
->setValue('%' . $keyword . '%')
->setConditionType('like')
->create();
// Filter group 2: by specific author
$authorFilter = $this->filterBuilder
->setField('author_id')
->setValue($authorId)
->setConditionType('eq')
->create();
// Multiple filters in same addFilters call = OR condition
// Different addFilter calls = AND condition
$searchCriteria = $this->searchCriteriaBuilder
->addFilters([$titleFilter, $authorFilter]) // title LIKE ... OR author_id = ...
->addFilter('status', 'published') // AND status = 'published'
->create();
return $this->postRepository->getList($searchCriteria)->getItems();
}
}
7. di.xml und webapi.xml: Bindung und API-Routing
<!-- 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">
<!-- Bind Repository Interface to Implementation -->
<preference for="Mironsoft\Blog\Api\PostRepositoryInterface"
type="Mironsoft\Blog\Model\PostRepository"/>
<!-- Bind Data Interface to Model -->
<preference for="Mironsoft\Blog\Api\Data\PostInterface"
type="Mironsoft\Blog\Model\Post"/>
<!-- Bind Search Results Interface -->
<preference for="Mironsoft\Blog\Api\Data\PostSearchResultsInterface"
type="Magento\Framework\Api\SearchResults"/>
</config>
<!-- etc/webapi.xml — exposes the repository as REST API automatically -->
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<!-- GET /V1/mironsoft/blog/posts/:postId -->
<route url="/V1/mironsoft/blog/posts/:postId" method="GET">
<service class="Mironsoft\Blog\Api\PostRepositoryInterface" method="getById"/>
<resources>
<resource ref="Mironsoft_Blog::post_read"/>
</resources>
</route>
<!-- POST /V1/mironsoft/blog/posts (create) -->
<route url="/V1/mironsoft/blog/posts" method="POST">
<service class="Mironsoft\Blog\Api\PostRepositoryInterface" method="save"/>
<resources>
<resource ref="Mironsoft_Blog::post_save"/>
</resources>
</route>
<!-- PUT /V1/mironsoft/blog/posts/:postId (update) -->
<route url="/V1/mironsoft/blog/posts/:postId" method="PUT">
<service class="Mironsoft\Blog\Api\PostRepositoryInterface" method="save"/>
<resources>
<resource ref="Mironsoft_Blog::post_save"/>
</resources>
</route>
<!-- DELETE /V1/mironsoft/blog/posts/:postId -->
<route url="/V1/mironsoft/blog/posts/:postId" method="DELETE">
<service class="Mironsoft\Blog\Api\PostRepositoryInterface" method="deleteById"/>
<resources>
<resource ref="Mironsoft_Blog::post_delete"/>
</resources>
</route>
<!-- GET /V1/mironsoft/blog/posts (list with search criteria) -->
<route url="/V1/mironsoft/blog/posts" method="GET">
<service class="Mironsoft\Blog\Api\PostRepositoryInterface" method="getList"/>
<resources>
<resource ref="Mironsoft_Blog::post_read"/>
</resources>
</route>
</routes>
8. REST API Exposition: automatisch aus Service Contracts
Einer der größten Vorteile von Service Contracts: Magento exposiert Repository-Methoden automatisch als REST API und GraphQL Endpoints – wenn sie korrekt in der webapi.xml registriert sind. Die Methode getList(SearchCriteriaInterface) wird sogar mit dem kompletten SearchCriteria-Filter-Syntax über Query-Parameter ansprechbar.
# Alle veröffentlichten Posts abrufen, neueste zuerst, Seite 1
GET /rest/V1/mironsoft/blog/posts
?searchCriteria[filter_groups][0][filters][0][field]=status
&searchCriteria[filter_groups][0][filters][0][value]=published
&searchCriteria[filter_groups][0][filters][0][condition_type]=eq
&searchCriteria[sort_orders][0][field]=published_at
&searchCriteria[sort_orders][0][direction]=DESC
&searchCriteria[current_page]=1
&searchCriteria[page_size]=10
# Response: { items: [...], total_count: 42, search_criteria: {...} }
# Einzelnen Post abrufen
GET /rest/V1/mironsoft/blog/posts/42
# Neuen Post erstellen
POST /rest/V1/mironsoft/blog/posts
Authorization: Bearer {admin_token}
Content-Type: application/json
{
"post": {
"title": "Mein erster Blogbeitrag",
"content": "<p>Inhalt...</p>",
"status": "draft",
"author_id": 1
}
}
Mironsoft
Magento 2 Modul-Entwicklung
Eigenes Magento 2 Modul mit Service Contracts entwickeln?
Wir entwickeln vollständige Magento 2 Module mit Service Contracts, Repositories, REST API und sauberen PHPUnit-Tests nach Best Practices.
9. Zusammenfassung
Das Repository Pattern mit Service Contracts ist der professionelle Standard für Datenzugriff in Magento 2. Es garantiert stabile APIs, ermöglicht einfaches Testen, und exposiert Daten automatisch über REST und GraphQL – ohne zusätzlichen Controller-Code.
Repository Pattern in Magento 2 – Checkliste
Data Interface (Api/Data/)
Nur Getter/Setter. Konstanten für Feldnamen. Kein Logik. Implementiert von Model-Klasse. In di.xml an Model binden.
Repository Interface (Api/)
getById, save, delete, deleteById, getList mit SearchCriteria. Exception-Typen deklarieren. In di.xml an Implementierung binden.
Repository Implementierung
In-Memory-Cache für wiederholte Loads. CouldNotSaveException / CouldNotDeleteException wrappen. CollectionProcessor für SearchCriteria verwenden.
REST API via webapi.xml
Route → Service Interface Methode. ACL-Resource für jeden Endpoint. getList() mit SearchCriteria wird automatisch per Query-Parameter ansprechbar.
10. FAQ: Repository Pattern in Magento 2
1 Repository vs. Resource Model – was ist der Unterschied?
2 Wie erstelle ich OR-Filter mit SearchCriteria?
addFilters([filterA, filterB]) = OR. Verschiedene addFilter()-Aufrufe = AND. addFilters() mit einem Array erstellt eine Filter-Gruppe; mehrere Gruppen werden immer AND-verknüpft.3 Brauche ich einen eigenen REST Controller für Service Contracts?
webapi.xml genügt: Route → Interface-Methode. Magento's WebAPI-Framework übernimmt Serialisierung, Deserialisierung, Authentifizierung und Fehlerbehandlung automatisch. Kein Controller nötig.4 Was gibt getById() zurück wenn die Entity nicht existiert?
NoSuchEntityException werfen — niemals null. Das ist der Service Contract Standard: Rückgabetypen sind nicht nullable. Konsumenten können so auf einen garantierten Typ vertrauen ohne null-Check.5 Wie implementiere ich einen In-Memory-Cache im Repository?
private array $cache = []; In getById(): prüfen ob ID im Cache, sonst laden und cachen. In save(): unset($this->cache[$id]) nach dem Speichern. Verhindert N+1-Queries wenn dieselbe Entity mehrfach im Request geladen wird.6 Kann ich ein Repository mit Plugins erweitern?
getById() für Datenanreicherung, Before Plugin auf save() für Validierungen. Bevorzugt gegenüber Preference, da mehrere Module gleichzeitig Plugins registrieren können.7 SearchCriteria vs. direkter Collection-Zugriff – was verwenden?
CollectionProcessor::process() mit SearchCriteria verwenden — nie addAttributeToFilter in der Service-Schicht.8 Wie teste ich ein Repository mit PHPUnit?
NoSuchEntityException bei fehlendem Objekt, CouldNotSaveException bei DB-Fehler geworfen wird.9 Muss jedes Modul ein Repository haben?
10 SearchResultsInterface vs. Array – was ist der Unterschied?
SearchResultsInterface enthält items, total_count (ohne Pagination) und search_criteria. Ermöglicht echte Pagination: Client weiß wie viele Seiten existieren. Array kann nur aktuelle Seite liefern — keine Gesamtanzahl. Immer SearchResultsInterface für getList() verwenden.