PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Service Contracts po 8 latach – antypatterns, SearchCriteria bez limitu, testowanie

by Henryk Tews / wtorek, 20 stycznia 2026 / Opublikowano w Magento 2

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.

About Henryk Tews

Co możesz przeczytać następne

Cron – grupy, własne joby, harmonogram z panelu admina, debugowanie
DI w Magento 2 – kontener, Virtual Types, Factory, shared/non-shared
Migracja danych prod → dev – mydumper, anonimizacja RODO, hooks automatyzujące setup
  • Publikacje
  • O autorze
  • Kontakt

© 2026 Created by

GÓRA
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Zawsze aktywne
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Zarządzaj opcjami
  • Zarządzaj serwisami
  • Zarządzaj {vendor_count} dostawcami
  • Przeczytaj więcej o tych celach
Zobacz preferencje
  • {title}
  • {title}
  • {title}