Magento 2 Service Contracts: Build Robust Modules
Service contracts are one of those topics that separates developers who understand Magento 2 from those who are just getting by. Most tutorials show you how to create a basic CRUD module. Few explain why the architecture decisions behind service contracts actually matter — or how to implement them properly when your module needs to survive upgrades, third-party integrations, and real production traffic.
This guide goes deeper. You'll implement a full service contract with search criteria support, extension attributes, and clean API boundaries that won't break when Adobe ships the next Commerce release.
What You'll Need
- Magento 2.4.6+ / Adobe Commerce 2.4.x - PHP 8.2+ - Basic familiarity with Magento 2 module structure - Composer-based installation (not Magento Marketplace zip drops)
---
What Are Service Contracts and Why They Matter in Magento 2
Magento 2 is a large, complex platform with dozens of modules that need to interact without stepping on each other. Service contracts are the formal agreement between your module and any code that consumes it — whether that's a REST API client, another module, or a GraphQL resolver.
A service contract consists of two parts:
Without service contracts, you're exposing concrete classes directly. Any consumer then couples tightly to your implementation details. Change a method signature in your model and you've silently broken every module that depended on it. Magento won't warn you — it'll just fail at runtime.
Service contracts also unlock automatic REST and GraphQL API generation. Define your interfaces correctly, and webapi.xml can expose them as REST endpoints with zero serialization code.
There's a catch, though: most tutorials only show a getById() and save() method, then call it done. Real modules need search criteria, collection filtering, and extension attributes so third-party developers can add fields without forking your code.
---
Setting Up Your Module Structure and Interfaces
The example module manages a simple Notification entity — a common real-world pattern. Here's the directory structure you're building toward:
app/code/Unomage/Notifications/
├── Api/
│ ├── Data/
│ │ ├── NotificationInterface.php
│ │ └── NotificationSearchResultsInterface.php
│ └── NotificationRepositoryInterface.php
├── Model/
│ ├── Notification.php
│ ├── NotificationRepository.php
│ └── ResourceModel/
│ ├── Notification.php
│ └── Notification/
│ └── Collection.php
├── etc/
│ ├── module.xml
│ └── di.xml
└── registration.php
Start with the data interface — the contract for your entity's shape:
<?php
// Api/Data/NotificationInterface.php
declare(strict_types=1);
namespace Unomage\Notifications\Api\Data;
interface NotificationInterface
{
public const NOTIFICATION_ID = 'notification_id';
public const TITLE = 'title';
public const MESSAGE = 'message';
public const IS_READ = 'is_read';
public const CREATED_AT = 'created_at';
public function getNotificationId(): ?int;
public function setNotificationId(int $id): self;
public function getTitle(): ?string;
public function setTitle(string $title): self;
public function getMessage(): ?string;
public function setMessage(string $message): self;
public function getIsRead(): bool;
public function setIsRead(bool $isRead): self;
public function getCreatedAt(): ?string;
public function setCreatedAt(string $createdAt): self;
}
PHP 8.2 strict typing means you catch signature mismatches at the interface level, not buried in a stack trace at 2am.
Now define the search results interface:
<?php
// Api/Data/NotificationSearchResultsInterface.php
declare(strict_types=1);
namespace Unomage\Notifications\Api\Data;
use Magento\Framework\Api\SearchResultsInterface;
interface NotificationSearchResultsInterface extends SearchResultsInterface
{
/* @return NotificationInterface[] /
public function getItems(): array;
/* @param NotificationInterface[] $items /
public function setItems(array $items): self;
}
Extending SearchResultsInterface gives you getTotalCount(), getSearchCriteria(), and pagination support for free. Don't skip this — it's what makes your module's list endpoints actually useful.
---
Implementing the Repository Pattern with Data Interfaces
The repository interface defines what operations your module exposes. Keep it focused:
<?php
// Api/NotificationRepositoryInterface.php
declare(strict_types=1);
namespace Unomage\Notifications\Api;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Unomage\Notifications\Api\Data\NotificationInterface;
use Unomage\Notifications\Api\Data\NotificationSearchResultsInterface;
interface NotificationRepositoryInterface
{
/* @throws CouldNotSaveException /
public function save(NotificationInterface $notification): NotificationInterface;
/* @throws NoSuchEntityException /
public function getById(int $notificationId): NotificationInterface;
/* @throws CouldNotDeleteException /
public function delete(NotificationInterface $notification): bool;
/* @throws CouldNotDeleteException /
public function deleteById(int $notificationId): bool;
public function getList(SearchCriteriaInterface $searchCriteria): NotificationSearchResultsInterface;
}
Pro tip: Always declare exceptions in your docblocks. Magento's REST API framework reads these to generate proper HTTP error responses. A
NoSuchEntityExceptionbecomes a 404; aCouldNotSaveExceptionbecomes a 500. Without the docblocks, the framework cannot map them correctly.
Now implement the repository — the concrete class that does the actual work:
<?php
// Model/NotificationRepository.php
declare(strict_types=1);
namespace Unomage\Notifications\Model;
use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\CouldNotDeleteException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\NoSuchEntityException;
use Unomage\Notifications\Api\Data\NotificationInterface;
use Unomage\Notifications\Api\Data\NotificationInterfaceFactory;
use Unomage\Notifications\Api\Data\NotificationSearchResultsInterface;
use Unomage\Notifications\Api\Data\NotificationSearchResultsInterfaceFactory;
use Unomage\Notifications\Api\NotificationRepositoryInterface;
use Unomage\Notifications\Model\ResourceModel\Notification as NotificationResource;
use Unomage\Notifications\Model\ResourceModel\Notification\CollectionFactory;
class NotificationRepository implements NotificationRepositoryInterface
{
public function __construct(
private readonly NotificationResource $resource,
private readonly NotificationInterfaceFactory $notificationFactory,
private readonly CollectionFactory $collectionFactory,
private readonly NotificationSearchResultsInterfaceFactory $searchResultsFactory,
private readonly CollectionProcessorInterface $collectionProcessor,
) {}
public function save(NotificationInterface $notification): NotificationInterface
{
try {
$this->resource->save($notification);
} catch (\Exception $e) {
throw new CouldNotSaveException(__($e->getMessage()));
}
return $notification;
}
public function getById(int $notificationId): NotificationInterface
{
$notification = $this->notificationFactory->create();
$this->resource->load($notification, $notificationId);
if (!$notification->getId()) {
throw new NoSuchEntityException(
__('Notification with ID "%1" does not exist.', $notificationId)
);
}
return $notification;
}
public function getList(SearchCriteriaInterface $searchCriteria): NotificationSearchResultsInterface
{
$collection = $this->collectionFactory->create();
$this->collectionProcessor->process($searchCriteria, $collection);
$searchResults = $this->searchResultsFactory->create();
$searchResults->setSearchCriteria($searchCriteria);
$searchResults->setItems($collection->getItems());
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
public function delete(NotificationInterface $notification): bool
{
try {
$this->resource->delete($notification);
} catch (\Exception $e) {
throw new CouldNotDeleteException(__($e->getMessage()));
}
return true;
}
public function deleteById(int $notificationId): bool
{
return $this->delete($this->getById($notificationId));
}
}
CollectionProcessorInterface handles filtering, sorting, and pagination from search criteria automatically. Don't rewrite that logic yourself — it's a common mistake that produces inconsistent behavior between your REST API and internal calls.
Warning: Never inject the
Collectionobject directly as a dependency. Always useCollectionFactory. Collections are stateful, so injecting them directly means every request shares the same filtered state — a reliable source of intermittent bugs that are difficult to reproduce.
---
Wiring It All Together with di.xml and Preferences
Interfaces mean nothing without preferences. di.xml tells Magento's dependency injection container which concrete class fulfils each interface:
<?xml version="1.0"?>
<!-- etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="Unomage\Notifications\Api\NotificationRepositoryInterface"
type="Unomage\Notifications\Model\NotificationRepository"/>
<preference for="Unomage\Notifications\Api\Data\NotificationInterface"
type="Unomage\Notifications\Model\Notification"/>
<preference for="Unomage\Notifications\Api\Data\NotificationSearchResultsInterface"
type="Magento\Framework\Api\SearchResults"/>
<type name="Unomage\Notifications\Model\NotificationRepository">
<arguments>
<argument name="collectionProcessor"
xsi:type="object">Magento\Framework\Api\SearchCriteria\CollectionProcessor</argument>
</arguments>
</type>
</config>
Using Magento\Framework\Api\SearchResults for your search results preference is the standard pattern — no need to create a concrete class for it.
Pro tip: If you're exposing this repository via REST API, add a
webapi.xmlentry. ThegetListendpoint with search criteria will automatically support filtering like?searchCriteria[filter_groups][0][filters][0][field]=is_read&searchCriteria[filter_groups][0][filters][0][value]=0— no custom serialization required.
---
Testing and Validating Your Service Contract Implementation
Once your module is enabled (bin/magento module:enable Unomage_Notifications && bin/magento setup:upgrade), validate the wiring before writing a single test.
Check DI compilation:
bin/magento setup:di:compile
If you've missed a preference or mistyped a class name, this catches it immediately — far faster than chasing a runtime ReflectionException through nested stack frames.
Integration test structure:
<?php
// Test/Integration/NotificationRepositoryTest.php
declare(strict_types=1);
namespace Unomage\Notifications\Test\Integration;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\TestCase;
use Unomage\Notifications\Api\Data\NotificationInterfaceFactory;
use Unomage\Notifications\Api\NotificationRepositoryInterface;
class NotificationRepositoryTest extends TestCase
{
private NotificationRepositoryInterface $repository;
private NotificationInterfaceFactory $notificationFactory;
private SearchCriteriaBuilder $searchCriteriaBuilder;
protected function setUp(): void
{
$objectManager = Bootstrap::getObjectManager();
$this->repository = $objectManager->get(NotificationRepositoryInterface::class);
$this->notificationFactory = $objectManager->get(NotificationInterfaceFactory::class);
$this->searchCriteriaBuilder = $objectManager->get(SearchCriteriaBuilder::class);
}
public function testSaveAndRetrieve(): void
{
$notification = $this->notificationFactory->create();
$notification->setTitle('Test Notification');
$notification->setMessage('This is a test.');
$notification->setIsRead(false);
$saved = $this->repository->save($notification);
$this->assertNotNull($saved->getNotificationId());
$fetched = $this->repository->getById((int) $saved->getNotificationId());
$this->assertSame('Test Notification', $fetched->getTitle());
}
public function testGetListWithSearchCriteria(): void
{
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('is_read', 0)
->setPageSize(10)
->create();
$results = $this->repository->getList($searchCriteria);
$this->assertIsArray($results->getItems());
$this->assertGreaterThanOrEqual(0, $results->getTotalCount());
}
}
Warning: Integration tests require a dedicated test database. Run them with
vendor/bin/phpunit -c dev/tests/integration/phpunit.xml. Never point integration tests at your production or development database — they roll back transactions, but accidents happen.
Spot-check the REST API:
curl -X GET \
"https://your-store.test/rest/V1/notifications?searchCriteria[pageSize]=5" \
-H "Authorization: Bearer YOUR_ADMIN_TOKEN"
A structured JSON response containing items and total_count confirms your service contracts are working correctly end-to-end.
---
Wrapping Up
Magento 2 service contracts aren't bureaucratic overhead — they're the mechanism that makes custom module development maintainable at scale. Define clean data interfaces, implement the repository pattern correctly, and wire everything through di.xml preferences, and you get three things most modules lack: upgrade safety, automatic API exposure, and a surface area that third parties can extend without touching your source code.
The patterns here — SearchCriteriaInterface, CollectionProcessorInterface, extension-ready data interfaces — are the same ones Adobe uses throughout Commerce core. Follow them, and your module will integrate cleanly with the rest of the platform rather than fighting against it.
The next step worth exploring is extension attributes: the mechanism that lets other modules add fields to your NotificationInterface without modifying it. That's where the "open for extension, closed for modification" principle really pays off in Magento 2.

