Repository Pattern in Magento 2: Service Contracts, SearchCriteria and Custom Repositories | Mironsoft
AI generated

Repository Pattern in Magento 2: Building Service Contracts, SearchCriteria and Custom Repositories

· Reading time: approx. 15 minutes · Part of the series: Design Patterns in Magento 2

API
Repo
Design Pattern #2 · Service Contracts

Repository Pattern
in Magento 2

Service Contracts, Data Interfaces, SearchCriteriaBuilder and REST API: build custom repositories that guarantee stable APIs across upgrades.

⏱ 15 min. PHP 8.4 Advanced REST API

The Repository Pattern: data access behind a stable API

The Repository Pattern is an abstraction layer between business logic and the data persistence layer. It encapsulates all database operations behind an interface, ensuring the calling code doesn't need to know anything about the database technology, the ORM, or the table structure in use.

In Magento 2, repositories are an integral part of Service Contracts, the concept of stable, versioned APIs between modules. A Service Contract consists of two parts:

  • Data Interfaces (Api/Data/): Define the data structure of an object (e.g. a blog post). No logic, just getters and setters.
  • Service Interfaces (Api/): Define the operations on that data (CRUD, search). This is the repository.

1. Directory structure of a complete Service Contract


app/code/Mironsoft/Blog/
├── Api/
│   ├── Data/
│   │   ├── PostInterface.php          ← Data Interface
│   │   └── PostSearchResultsInterface.php
│   └── PostRepositoryInterface.php   ← Service Interface (Repository)
│
├── Model/
│   ├── Post.php                       ← Model + Data Interface implementation
│   ├── PostRepository.php             ← Repository implementation
│   └── ResourceModel/
│       ├── Post.php                   ← Resource Model (DB access)
│       └── Post/
│           └── Collection.php         ← Collection
│
├── etc/
│   ├── di.xml                         ← Preference + Factory binding
│   └── webapi.xml                     ← REST API routing
│
└── registration.php

2. Data Interface: defining the data structure

The Data Interface defines the properties of a data object using getter and setter methods. It lives under Api/Data/ and guarantees that every consumer can rely on the same data structure.


<?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: defining the service API


<?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 and Collection

The Model implements the Data Interface and inherits from Magento's AbstractModel. The Resource Model handles the actual database access.


<?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 implementation

The repository implementation encapsulates all database operations, handles exceptions, and includes a simple in-memory cache for repeated access to the same object.


<?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: flexible, type-safe searching

The SearchCriteriaBuilder is the builder for database queries across repositories. It allows filtering, sorting, and pagination in a declarative, type-safe way, without writing SQL.


<?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 and webapi.xml: binding and 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 exposure: automatic from Service Contracts

One of the biggest advantages of Service Contracts: Magento automatically exposes repository methods as REST API and GraphQL endpoints, provided they are correctly registered in webapi.xml. The getList(SearchCriteriaInterface) method can even be addressed with the complete SearchCriteria filter syntax via query parameters.


# Fetch all published posts, newest first, page 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: {...} }

# Fetch a single post
GET /rest/V1/mironsoft/blog/posts/42

# Create a new post
POST /rest/V1/mironsoft/blog/posts
Authorization: Bearer {admin_token}
Content-Type: application/json

{
  "post": {
    "title": "My first blog post",
    "content": "<p>Content...</p>",
    "status": "draft",
    "author_id": 1
  }
}

Mironsoft

Magento 2 module development

Need a custom Magento 2 module built with Service Contracts?

We build complete Magento 2 modules with Service Contracts, repositories, REST API, and clean PHPUnit tests following best practices.

Service Contracts
Data Interfaces, Repository Interfaces, and implementations built to Magento best practices.
REST API exposure
Your own repositories automatically exposed as REST API with ACL protection and full SearchCriteria support.
Repository tests
PHPUnit integration tests for repositories with a real database connection and full coverage.

9. Summary

The Repository Pattern combined with Service Contracts is the professional standard for data access in Magento 2. It guarantees stable APIs, makes testing straightforward, and automatically exposes data via REST and GraphQL, without any additional controller code.

Repository Pattern in Magento 2: checklist

Data Interface (Api/Data/)

Getters/setters only. Constants for field names. No logic. Implemented by the Model class. Bound to the Model in di.xml.

Repository Interface (Api/)

getById, save, delete, deleteById, getList with SearchCriteria. Declare exception types. Bound to the implementation in di.xml.

Repository implementation

In-memory cache for repeated loads. Wrap CouldNotSaveException / CouldNotDeleteException. Use CollectionProcessor for SearchCriteria.

REST API via webapi.xml

Route maps to a Service Interface method. ACL resource for every endpoint. getList() with SearchCriteria automatically becomes addressable via query parameters.

10. FAQ: Repository Pattern in Magento 2

1 Repository vs. Resource Model: what is the difference?
Resource Model = the lowest DB layer (tables, SQL). Repository = public API with Service Contract interface, in-memory cache, exception wrapping. Consumers always use the repository, never the Resource Model directly.
2 How do I create OR filters with SearchCriteria?
Filters inside addFilters([filterA, filterB]) = OR. Separate addFilter() calls = AND. addFilters() with an array creates one filter group; multiple groups are always combined with AND.
3 Do I need my own REST controller for Service Contracts?
No. webapi.xml is enough: route maps to an interface method. Magento's WebAPI framework handles serialization, deserialization, authentication, and error handling automatically. No controller required.
4 What does getById() return when the entity doesn't exist?
The repository must throw NoSuchEntityException, never null. This is the Service Contract standard: return types are not nullable. Consumers can rely on a guaranteed type without a null check.
5 How do I implement an in-memory cache in a repository?
private array $cache = []; In getById(): check whether the ID is cached, otherwise load and cache it. In save(): unset($this->cache[$id]) after saving. Prevents N+1 queries when the same entity is loaded multiple times in a request.
6 Can I extend a repository using plugins?
Yes, register a plugin on the Repository Interface (not the implementation). After plugin on getById() for data enrichment, Before plugin on save() for validation. Preferred over a Preference, since multiple modules can register plugins at the same time.
7 SearchCriteria vs. direct collection access: which should I use?
SearchCriteria is type-safe and API-compatible (identical across REST, GraphQL, PHP). Direct collection access is internal and ORM-specific. Inside the repository, use CollectionProcessor::process() with SearchCriteria, never addAttributeToFilter in the service layer.
8 How do I test a repository with PHPUnit?
Unit tests: mock the Resource Model, Factory, and CollectionFactory. Integration tests (preferred): use a real DB connection, they catch issues that mocks miss. Test whether NoSuchEntityException is thrown when the object is missing, and CouldNotSaveException on a DB error.
9 Does every module need a repository?
Not necessarily. Rule of thumb: if other modules need access to the data, or a REST API is required, use a repository. For purely internal use, use the collection directly. No repository is needed for temporary or configuration objects.
10 SearchResultsInterface vs. array: what is the difference?
SearchResultsInterface contains items, total_count (without pagination), and search_criteria. It enables true pagination: the client knows how many pages exist. An array can only deliver the current page, with no total count. Always use SearchResultsInterface for getList().