Service Contracts to fundament który odróżnia Magento 2 od poprzedniej wersji. Interfejsy z przestrzeni Api\, repositories, search criteria – w teorii czyste i eleganckie, w praktyce pełne pułapek które widzę regularnie w code review. Przez osiem lat zebrałem zestaw zasad których przestrzegam i antypatternów których unikam. Czas to zebrać w jedno miejsce.
Hierarchia interfejsów – czego nie mieszać
<?php
// Magento 2 ma trzy warstwy interfejsów - każda ma inne przeznaczenie
// 1. Data Interfaces (Api\Data\) - transfer danych, DTO
// Np. Magento\Catalog\Api\Data\ProductInterface
// Zasada: tylko gettery i settery, żadnej logiki biznesowej
interface ProductInterface extends \Magento\Framework\Api\ExtensibleDataInterface
{
public function getSku(): string;
public function getName(): ?string;
public function getPrice(): ?float;
public function setSku(string $sku): static;
// ...
}
// 2. Repository Interfaces (Api\) - dostęp do danych
// Np. Magento\Catalog\Api\ProductRepositoryInterface
// Zasada: get, getList, save, delete - i nic więcej
interface ProductRepositoryInterface
{
public function get(string $sku, bool $editMode = false, ?int $storeId = null, bool $forceReload = false): ProductInterface;
public function getById(int $productId, bool $editMode = false, ?int $storeId = null, bool $forceReload = false): ProductInterface;
public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria): \Magento\Framework\Api\SearchResultsInterface;
public function save(ProductInterface $product): ProductInterface;
public function delete(ProductInterface $product): bool;
public function deleteById(int $productId): bool;
}
// 3. Management Interfaces (Api\) - operacje biznesowe
// Np. Magento\Catalog\Api\ProductManagementInterface
// Zasada: złożone operacje które nie pasują do Repository
interface ProductManagementInterface
{
public function getCount(?int $storeId = null): int;
}
Własne Service Contracts – jak je pisać
<?php
declare(strict_types=1);
// Przykład: własny moduł do zarządzania punktami lojalnościowymi
// 1. Data Interface - czysty DTO
namespace Vendor\Loyalty\Api\Data;
interface LoyaltyAccountInterface extends \Magento\Framework\Api\ExtensibleDataInterface
{
public const CUSTOMER_ID = 'customer_id';
public const POINTS = 'points';
public const TIER = 'tier';
public const EXPIRES_AT = 'expires_at';
public function getCustomerId(): int;
public function getPoints(): int;
public function getTier(): string;
public function getExpiresAt(): ?string;
public function setCustomerId(int $customerId): static;
public function setPoints(int $points): static;
public function setTier(string $tier): static;
public function setExpiresAt(?string $expiresAt): static;
}
// 2. Repository Interface - standardowy CRUD
namespace Vendor\Loyalty\Api;
interface LoyaltyAccountRepositoryInterface
{
public function getByCustomerId(int $customerId): \Vendor\Loyalty\Api\Data\LoyaltyAccountInterface;
public function save(\Vendor\Loyalty\Api\Data\LoyaltyAccountInterface $account): \Vendor\Loyalty\Api\Data\LoyaltyAccountInterface;
public function delete(\Vendor\Loyalty\Api\Data\LoyaltyAccountInterface $account): bool;
}
// 3. Management Interface - złożone operacje biznesowe
namespace Vendor\Loyalty\Api;
interface LoyaltyManagementInterface
{
// Operacje których nie da się wyrazić przez prosty save
public function earnPoints(int $customerId, int $points, string $source): \Vendor\Loyalty\Api\Data\LoyaltyAccountInterface;
public function redeemPoints(int $customerId, int $points, int $orderId): bool;
public function expireOldPoints(): int; // zwraca liczbę wygasłych kont
}
Najczęstsze antypatterns – co widzę w code review
<?php
declare(strict_types=1);
// ANTYPATTERN 1: Używanie Model zamiast Repository
// ŹLE
class OrderService
{
public function __construct(
private \Magento\Sales\Model\OrderFactory $orderFactory // Model bezpośrednio!
) {}
public function getOrder(int $id): \Magento\Sales\Model\Order
{
$order = $this->orderFactory->create();
$order->load($id); // Deprecated metoda, wolne, omija cache
return $order;
}
}
// DOBRZE
class OrderService
{
public function __construct(
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository
) {}
public function getOrder(int $id): \Magento\Sales\Api\Data\OrderInterface
{
return $this->orderRepository->get($id); // Cache, pluginy, extensibility
}
}
// ANTYPATTERN 2: SearchCriteria bez limitów – zapytanie bez LIMIT
// ŹLE
public function getAllActiveProducts(): array
{
$sc = $this->searchCriteriaBuilder
->addFilter('status', 1)
->create();
// Brak setPageSize() = SELECT bez LIMIT = może zwrócić 100k produktów!
return $this->productRepository->getList($sc)->getItems();
}
// DOBRZE
public function getActiveProductsPage(int $page = 1, int $pageSize = 100): array
{
$sc = $this->searchCriteriaBuilder
->addFilter('status', 1)
->setPageSize($pageSize)
->setCurrentPage($page)
->create();
return $this->productRepository->getList($sc)->getItems();
}
// ANTYPATTERN 3: Tworzenie SearchCriteria bez budowniczego – trudne do testowania
// ŹLE
$sc = new \Magento\Framework\Api\SearchCriteria(); // bezpośredni new
$sc->setPageSize(20);
// Problem: nie da się podmienić w testach przez DI
// DOBRZE
$sc = $this->searchCriteriaBuilder
->setPageSize(20)
->create();
// SearchCriteriaBuilder wstrzykiwany przez konstruktor = testowalny
SearchCriteria – zaawansowane filtrowanie
<?php
declare(strict_types=1);
// Złożone warunki z FilterGroup (OR między grupami, AND w grupie)
// Chcemy: (status = 'pending' OR status = 'processing') AND price > 100
$filter1 = $this->filterBuilder
->setField('status')
->setValue('pending')
->setConditionType('eq')
->create();
$filter2 = $this->filterBuilder
->setField('status')
->setValue('processing')
->setConditionType('eq')
->create();
// OR: oba filtry w jednej grupie
$filterGroup1 = $this->filterGroupBuilder
->setFilters([$filter1, $filter2])
->create();
$filter3 = $this->filterBuilder
->setField('grand_total')
->setValue(100)
->setConditionType('gt')
->create();
// AND: drugi warunek w osobnej grupie
$filterGroup2 = $this->filterGroupBuilder
->setFilters([$filter3])
->create();
$sc = $this->searchCriteriaBuilder
->setFilterGroups([$filterGroup1, $filterGroup2]) // AND między grupami
->addSortOrder(
$this->sortOrderBuilder->setField('created_at')->setDescendingDirection()->create()
)
->setPageSize(20)
->setCurrentPage(1)
->create();
$orders = $this->orderRepository->getList($sc)->getItems();
Testowanie Service Contracts
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\MockObject\MockObject;
class LoyaltyServiceTest extends TestCase
{
private MockObject $accountRepository;
private MockObject $loyaltyManagement;
private LoyaltyService $service;
protected function setUp(): void
{
// Mockujemy interfejsy, nie konkretne klasy
// Dzięki temu test nie zależy od implementacji
$this->accountRepository = $this->createMock(
\Vendor\Loyalty\Api\LoyaltyAccountRepositoryInterface::class
);
$this->loyaltyManagement = $this->createMock(
\Vendor\Loyalty\Api\LoyaltyManagementInterface::class
);
$this->service = new LoyaltyService(
$this->accountRepository,
$this->loyaltyManagement
);
}
public function testGetsTierCorrectly(): void
{
$account = $this->createMock(\Vendor\Loyalty\Api\Data\LoyaltyAccountInterface::class);
$account->method('getPoints')->willReturn(1500);
$account->method('getTier')->willReturn('gold');
$this->accountRepository
->expects($this->once())
->method('getByCustomerId')
->with(42)
->willReturn($account);
$tier = $this->service->getCustomerTier(42);
$this->assertEquals('gold', $tier);
}
}
Podsumowanie
Po ośmiu latach pracy z Magento 2 Service Contracts widzę te same błędy w nowych projektach co w 2018. Trzy najważniejsze zasady które zmieniają jakość kodu: zawsze Repository zamiast bezpośrednich modeli, zawsze setPageSize() w każdym getList() i mockuj interfejsy w testach. Architektura Service Contracts jest skomplikowana bo jest elastyczna – ta elastyczność jest wartością gdy ją rozumiesz i przekleństwem gdy kopiujesz kod bez refleksji.
