Magento 2 has a built-in Service Contracts system based on interfaces and Data Interfaces. Instead of injecting models directly, you inject Repository and Data Interface interfaces. This is not just good practice – it is the only way to make a module work stably after updates and be compatible with other modules. I show how to correctly use Service Contracts using the product catalogue as an example.
Why not use models directly
<?php
// Bad - direct dependency on model
class BadOrderService
{
public function __construct(
private \Magento\Sales\Model\Order $order,
private \Magento\Sales\Model\ResourceModel\Order\Collection $collection,
) {}
// Problem 1: Model may change API between versions
// Problem 2: Collection loads everything into memory without limit
// Problem 3: Hard to test (mocking concrete classes)
// Problem 4: No guarantee another implementation works the same way
}
// Good - dependency on interfaces
class GoodOrderService
{
public function __construct(
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
private \Magento\Framework\Api\SearchCriteriaBuilder $searchCriteriaBuilder,
) {}
}
Repository Interface – correct data access
<?php
declare(strict_types=1);
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\SortOrderBuilder;
class ProductService
{
public function __construct(
private ProductRepositoryInterface $productRepository,
private SearchCriteriaBuilder $searchCriteriaBuilder,
private SortOrderBuilder $sortOrderBuilder,
) {}
public function getById(int $id): ProductInterface
{
return $this->productRepository->getById($id);
}
public function getBySku(string $sku): ProductInterface
{
return $this->productRepository->get($sku);
}
public function getActiveByCategory(int $categoryId, int $page = 1, int $size = 20): array
{
$sortOrder = $this->sortOrderBuilder
->setField('created_at')
->setDescendingDirection()
->create();
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('status', 1)
->addFilter('visibility', [2, 3, 4], 'in')
->addFilter('category_id', $categoryId)
->addSortOrder($sortOrder)
->setPageSize($size)
->setCurrentPage($page)
->create();
$result = $this->productRepository->getList($searchCriteria);
return [
'items' => $result->getItems(),
'total_count' => $result->getTotalCount(),
'page_size' => $size,
'current_page' => $page,
];
}
}
Data Interface – creating and modifying
<?php
declare(strict_types=1);
class ProductCreator
{
public function __construct(
private \Magento\Catalog\Api\Data\ProductInterfaceFactory $productFactory,
private \Magento\Catalog\Api\ProductRepositoryInterface $productRepository,
) {}
public function create(array $data): \Magento\Catalog\Api\Data\ProductInterface
{
$product = $this->productFactory->create();
$product->setSku($data['sku']);
$product->setName($data['name']);
$product->setPrice($data['price']);
$product->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED);
$product->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH);
$product->setTypeId('simple');
$product->setAttributeSetId(4);
return $this->productRepository->save($product);
}
public function updatePrice(string $sku, float $newPrice): void
{
$product = $this->productRepository->get($sku);
$product->setPrice($newPrice);
$this->productRepository->save($product);
}
}
Custom Service Contract – your own module
<?php
declare(strict_types=1);
// api/Data/LoyaltyPointsInterface.php
namespace Vendor\Loyalty\Api\Data;
interface LoyaltyPointsInterface
{
public function getCustomerId(): int;
public function setCustomerId(int $customerId): self;
public function getPoints(): int;
public function setPoints(int $points): self;
public function getExpiresAt(): ?string;
public function setExpiresAt(?string $date): self;
}
// api/LoyaltyRepositoryInterface.php
namespace Vendor\Loyalty\Api;
interface LoyaltyRepositoryInterface
{
public function getByCustomerId(int $customerId): \Vendor\Loyalty\Api\Data\LoyaltyPointsInterface;
public function save(\Vendor\Loyalty\Api\Data\LoyaltyPointsInterface $points): \Vendor\Loyalty\Api\Data\LoyaltyPointsInterface;
public function deleteByCustomerId(int $customerId): bool;
}
<!-- etc/di.xml - bind interfaces to implementations -->
<preference for="Vendor\Loyalty\Api\Data\LoyaltyPointsInterface"
type="Vendor\Loyalty\Model\LoyaltyPoints"/>
<preference for="Vendor\Loyalty\Api\LoyaltyRepositoryInterface"
type="Vendor\Loyalty\Model\LoyaltyRepository"/>
Summary
Service Contracts in Magento 2 are interfaces that define a module’s public API. Repository Interface provides data access through SearchCriteria instead of direct queries. Data Interface defines the object structure independently of the implementation. A custom Service Contract has three elements: Data Interface, Repository Interface and di.xml that binds them to the implementation. This standard makes modules mutually compatible and resilient to updates.
