Singleton Pattern in Magento 2: Wo es steckt und warum du es vermeidest

· Lesezeit: ca. 13 Minuten · Kategorie: Magento 2 · Design Patterns

shared
Magento 2 · Deep Dive · Design Patterns

Singleton Pattern
in Magento 2 verstehen

Magentos DI-Container erstellt standardmäßig Shared Objects – das ist Singleton-Verhalten. Wo es nützlich ist, wo es Probleme verursacht und wie du Legacy-Singletons sauber refaktorierst.

⏱ 13 Min. Deep Dive Design Patterns PHP 8.4

Das unsichtbare Singleton

Das Singleton Pattern gilt in modernem PHP als Anti-Pattern – und trotzdem ist es tief in Magento 2 verankert. Nicht als explizite getInstance()-Implementierung wie in Magento 1, sondern als Standardverhalten des DI-Containers: Jede Klasse, die über Constructor Injection bezogen wird, ist per Default ein Shared Object – also ein Singleton für die Lebenszeit des Requests.

Das ist oft das Richtige. Aber es ist auch eine Quelle von subtilen Bugs, Testproblemen und unerwünschten Seiteneffekten. Wer Magento-Code schreibt, ohne das zu verstehen, produziert Code der in Produktion anders läuft als im Test.

Dieser Deep Dive erklärt: Wie Magentos Singleton-Mechanismus funktioniert, wo er nützlich ist, wo er gefährlich wird – und wie du Singletons erkennst und ersetzt.

1. Das GoF Singleton Pattern

Das Singleton Pattern aus dem GoF-Buch (1994) stellt sicher, dass von einer Klasse genau eine Instanz existiert und diese global zugänglich ist. Die klassische Implementierung in PHP:


<?php
// Klassisches Singleton (PHP Anti-Pattern):
class Registry
{
    private static ?self $instance = null;
    private array $data = [];

    // Private constructor: kein direktes new Registry()
    private function __construct() {}

    // Globaler Einstiegspunkt
    public static function getInstance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    public function set(string $key, mixed $value): void
    {
        $this->data[$key] = $value;
    }

    public function get(string $key): mixed
    {
        return $this->data[$key] ?? null;
    }
}

// Verwendung: überall im Code
Registry::getInstance()->set('current_product', $product);
$product = Registry::getInstance()->get('current_product');

Magento 1 war voll davon. Magento 2 hat das offiziell abgeschafft – aber durch die Hintertür zurückgebracht, diesmal im DI-Container.

2. Shared vs. Non-shared Objects: Magentos Singleton-Mechanismus

In Magento 2 gibt es keine expliziten Singletons mehr. Stattdessen gibt es Shared Objects: Der DI-Container (ObjectManager) erstellt von jeder Klasse standardmäßig nur eine Instanz pro Request und gibt diese bei jedem weiteren Aufruf wieder zurück.


<!-- etc/di.xml: Shared ist DEFAULT (muss nicht angegeben werden) -->
<config>
    <!-- Diese zwei Konfigurationen sind identisch: -->

    <!-- Explizit als shared (Singleton-Verhalten): -->
    <type name="Mironsoft\Blog\Model\Config" shared="true"/>

    <!-- Implizit als shared (Default-Verhalten): -->
    <type name="Mironsoft\Blog\Model\Config"/>

    <!-- Explizit als NON-shared (neue Instanz bei jedem resolve): -->
    <type name="Mironsoft\Blog\Model\Post" shared="false"/>
</config>

<?php
// Shared Object: gleiche Instanz, egal wie oft injiziert
class ServiceA
{
    public function __construct(
        private readonly Config $config // Instanz #1
    ) {}
}

class ServiceB
{
    public function __construct(
        private readonly Config $config // DIESELBE Instanz wie oben
    ) {}
}

// Wenn ServiceA und ServiceB über DI instanziiert werden,
// bekommen beide exakt dieselbe Config-Instanz.
// Das ist Singleton-Verhalten — nur versteckt im Container.

Regel: Services (Config, Logger, Repositories, ViewModels) sollten shared sein – sie haben keinen Request-spezifischen Zustand. Models (Post, Product, Order) sollten nicht shared sein – sie repräsentieren spezifische Datensätze.

3. ObjectManager und Singletons

Der ObjectManager verwaltet intern zwei Pools: einen für Shared Objects (Singletons) und einen Factory-Mechanismus für Non-shared Objects. Direkte ObjectManager::get()-Aufrufe sind deshalb in eigentlichem Anwendungscode verboten:


<?php
// FALSCH: Direkter ObjectManager-Aufruf (Service Locator Anti-Pattern)
namespace Mironsoft\Blog\Block;

class Post extends \Magento\Framework\View\Element\Template
{
    public function getConfig(): Config
    {
        // ObjectManager::get() = Singleton-Pool direkt ansprechen
        // Das ist verboten in eigenem Code!
        return \Magento\Framework\App\ObjectManager::getInstance()->get(Config::class);
        // Probleme:
        // 1. Versteckte Abhängigkeit — kein PHPDoc, kein Constructor
        // 2. Nicht testbar — Mock kann nicht injiziert werden
        // 3. Globaler Zugriff = Singleton-Anti-Pattern
    }
}

// RICHTIG: Constructor Injection
class Post extends \Magento\Framework\View\Element\Template
{
    public function __construct(
        \Magento\Framework\View\Element\Template\Context $context,
        private readonly Config $config, // Explizite Abhängigkeit, testbar
        array $data = []
    ) {
        parent::__construct($context, $data);
    }

    public function getConfig(): Config
    {
        return $this->config; // Config-Instanz ist shared — kein Problem
    }
}

Erlaubte Ausnahmen für ObjectManager-Direktzugriff: Factories, Proxies, Test-Setup und Installer-Klassen. In regulären Services, Blocks, Controllern oder ViewModels: niemals.

4. Versteckte Singletons: Wo sie wirklich stecken

Singletons in Magento 2 sind nicht explizit markiert. Du erkennst sie daran, dass sie statusbehafteten Zustand halten der sich über mehrere Aufrufe verändert:


Versteckte Singletons in Magento Core:

Magento\Framework\Registry
  → Globaler Key-Value-Store (Legacy!)
  → Jeder schreibt rein, jeder liest — Singleton-Verhalten explizit

Magento\Framework\App\Config\ScopeConfigInterface
  → Konfiguration gecacht nach erstem Lesen
  → Shared Object — eine Instanz für alle

Magento\Customer\Model\Session
  → Session-Daten des aktuellen Kunden
  → Shared Object — gefährlich in CLI und Tests

Magento\Framework\Pricing\PriceCurrencyInterface
  → Währungskonvertierung mit gecachtem Kurs
  → Shared — Cache bleibt über den Request

Magento\Catalog\Model\ResourceModel\Product\Collection
  → NICHT shared (shared="false")!
  → Jede Abfrage braucht eine neue Collection-Instanz

<?php
// Das Registry-Problem: Singletons mit globalem Schreibzugriff
// Magento\Framework\Registry ist ein expliziter Singleton:

class ProductView
{
    public function __construct(
        private readonly \Magento\Framework\Registry $registry
    ) {}

    public function execute(): void
    {
        // Schreibt in den globalen State
        $this->registry->register('current_product', $product);
    }
}

class ProductPrice
{
    public function __construct(
        private readonly \Magento\Framework\Registry $registry
    ) {}

    public function getPrice(): float
    {
        // Liest aus dem globalen State — implizite Kopplung!
        $product = $this->registry->registry('current_product');
        // Was wenn ProductView noch nicht gelaufen ist?
        // Was wenn ein anderer Code 'current_product' überschreibt?
        return $product?->getPrice() ?? 0.0;
    }
}
// Registry ist deprecated seit Magento 2.3 — ersetze es mit ViewModel!

5. Warum Singletons Probleme verursachen

Shared Objects mit veränderlichem Zustand sind die gefährlichste Form des Singleton-Patterns. Drei konkrete Probleme:


<?php
// Problem 1: Zustandskorruption zwischen Aufrufen
class PriceCalculator
{
    private float $discount = 0.0; // MUTABLE STATE in einem Shared Object!

    public function setDiscount(float $discount): void
    {
        $this->discount = $discount;
    }

    public function calculate(float $price): float
    {
        return $price * (1 - $this->discount);
    }
}

// Wenn PriceCalculator shared ist:
// Aufruf 1: setDiscount(0.1) → calculate() → 90.0 ✓
// Aufruf 2: calculate() → 90.0 statt 100.0 ✗
//   → Discount bleibt aus Aufruf 1 gesetzt!

// Lösung: Entweder Non-shared oder zustandslos (immutable) machen:
class PriceCalculator
{
    // Kein interner Zustand — alle Parameter explizit:
    public function calculate(float $price, float $discount = 0.0): float
    {
        return $price * (1 - $discount);
    }
}

<?php
// Problem 2: Session-Singleton in CLI-Umgebung
class CustomerPriceProvider
{
    public function __construct(
        private readonly \Magento\Customer\Model\Session $customerSession
    ) {}

    public function getCustomerGroupId(): int
    {
        // Im Browser: funktioniert, Session hat Kundendaten
        // In CLI (cron, import): Session ist leer/fehlerhaft!
        // Der Singleton-Mechanismus gibt die GLEICHE Session-Instanz zurück
        // — aber in CLI gibt es keine HTTP-Session!
        return (int) $this->customerSession->getCustomerGroupId();
    }
}

// Lösung: CustomerGroupId direkt aus Repository holen,
// oder Proxy nutzen: \Magento\Customer\Model\Session\Proxy

<?php
// Problem 3: Collection-Singleton (wenn shared="true" gesetzt würde)
// Collections sammeln alle geladenen Items — bei shared=true
// würden alle Module dieselbe Collection teilen!

// FALSCH (hypothetisch):
// <type name="Magento\Catalog\Model\ResourceModel\Product\Collection" shared="true"/>

class ProductListA
{
    public function __construct(
        private readonly \Magento\Catalog\Model\ResourceModel\Product\Collection $collection
    ) {}

    public function getProducts(): array
    {
        $this->collection->addFieldToFilter('status', 1); // filtert aktive Produkte
        return $this->collection->getItems();
    }
}

class ProductListB
{
    public function __construct(
        private readonly \Magento\Catalog\Model\ResourceModel\Product\Collection $collection
    ) {}

    public function getDisabledProducts(): array
    {
        // GLEICHE Collection-Instanz wie oben! Status-Filter von A ist noch aktiv.
        $this->collection->addFieldToFilter('status', 0);
        // Ergebnis: falsche Produkte, weil beide Filter aktiv sind
        return $this->collection->getItems();
    }
}
// Deshalb ist Collection explizit shared="false"!

6. Singleton vs. Testbarkeit: Konkrete Probleme

Singletons sind der Hauptgrund dafür, dass Code schwer testbar ist. Drei Muster, die in Tests scheitern:


<?php
// Test-Problem: ObjectManager::getInstance() lässt sich nicht mocken
class LegacyService
{
    public function getProductName(int $id): string
    {
        // Direkte ObjectManager-Abhängigkeit — nicht injectable!
        $product = \Magento\Framework\App\ObjectManager::getInstance()
            ->create(\Magento\Catalog\Model\Product::class);
        $product->load($id);
        return $product->getName();
    }
}

// Im Test: Unmöglich ohne Magento-Bootstrap!
// PHPUnit kann keine Instanz für ObjectManager::getInstance() injizieren.

// Refaktoriertes Version — testbar:
class ModernService
{
    public function __construct(
        private readonly \Magento\Catalog\Api\ProductRepositoryInterface $productRepository
    ) {}

    public function getProductName(int $id): string
    {
        return $this->productRepository->getById($id)->getName();
    }
}

// Im Test:
class ModernServiceTest extends \PHPUnit\Framework\TestCase
{
    public function testGetProductName(): void
    {
        $product = $this->createMock(\Magento\Catalog\Api\Data\ProductInterface::class);
        $product->method('getName')->willReturn('Test Product');

        $repository = $this->createMock(\Magento\Catalog\Api\ProductRepositoryInterface::class);
        $repository->method('getById')->with(42)->willReturn($product);

        $service = new ModernService($repository);
        $this->assertSame('Test Product', $service->getProductName(42));
    }
}

7. Non-shared Objects: Wann neue Instanzen nötig sind

Manche Klassen müssen immer als neue Instanz erstellt werden. Dazu gehören alle Klassen, die Request-spezifischen oder Datensatz-spezifischen Zustand halten:


<!-- etc/di.xml: Non-shared konfigurieren -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <!-- Models: repräsentieren spezifische Datensätze — NIE shared -->
    <type name="Mironsoft\Blog\Model\Post" shared="false"/>
    <type name="Mironsoft\Blog\Model\Comment" shared="false"/>

    <!-- Data Objects: reine Datentransporter — NIE shared -->
    <type name="Mironsoft\Blog\Model\Data\PostData" shared="false"/>

    <!-- SearchResults: Ergebnis einer spezifischen Suche — NIE shared -->
    <type name="Mironsoft\Blog\Model\SearchResults" shared="false"/>

    <!-- Services: zustandslos, gecacht in Ordnung — shared (Default) -->
    <!-- type name="Mironsoft\Blog\Model\PostRepository" shared="true"/ -->
    <!-- Nicht nötig, ist Default -->
</config>

Faustregel: Alles was Daten eines spezifischen Datensatzes hält (Model, Data Object, Collection-Item) → Non-shared. Alles was Services oder Logik bereitstellt ohne eigenen zustandsbehafteten Datenbezug → Shared.

8. Factory als Lösung: Neue Instanzen on demand

Wenn du in einem Shared-Service eine neue Non-shared-Instanz brauchst, nutzt du eine Factory. Magento generiert Factories automatisch für jede Klasse, die mit dem Suffix Factory referenziert wird:


<?php
declare(strict_types=1);

namespace Mironsoft\Blog\Model;

use Mironsoft\Blog\Api\Data\PostInterface;
use Mironsoft\Blog\Model\PostFactory;
use Mironsoft\Blog\Model\ResourceModel\Post as PostResource;

/**
 * Repository creates new Post instances via Factory — never via new or ObjectManager.
 */
class PostRepository
{
    public function __construct(
        private readonly PostFactory $postFactory,  // Automatisch generiert!
        private readonly PostResource $postResource
    ) {}

    /**
     * Create a fresh Post instance for each getById() call.
     * If PostRepository were shared (it is), but Post is non-shared,
     * the factory ensures a new Post object every time.
     */
    public function getById(int $id): PostInterface
    {
        // Factory::create() = new Post() via DI-Container
        // Non-shared: jeder create()-Aufruf gibt neue Instanz
        $post = $this->postFactory->create();

        $this->postResource->load($post, $id);

        if (!$post->getId()) {
            throw new \Magento\Framework\Exception\NoSuchEntityException(
                __('Post with id "%1" does not exist.', $id)
            );
        }

        return $post;
    }
}

Automatische Factory-Generierung: Du musst PostFactory nicht selbst schreiben. Sobald du die Klasse als Dependency mit dem Suffix Factory angibst, generiert bin/magento setup:di:compile die Klasse automatisch in generated/code/. Die generierte Factory ruft intern ObjectManager::create(Post::class) auf – non-shared.

9. Legacy-Singletons refaktorieren

Typische Singleton-Anti-Patterns in Magento-Code und deren moderne Alternativen:


<?php
// ANTI-PATTERN 1: Registry für Datenweitergabe
// Alt (deprecated):
class ProductController
{
    public function execute(): ResultInterface
    {
        $product = $this->productRepository->getById(42);
        $this->registry->register('current_product', $product); // Global!
        return $this->pageFactory->create();
    }
}
class ProductBlock extends Template
{
    public function getProduct(): ?ProductInterface
    {
        return $this->registry->registry('current_product'); // Globaler Zugriff!
    }
}

// Neu (ViewModel-Pattern):
class ProductViewModel implements ArgumentInterface
{
    public function __construct(
        private readonly RequestInterface $request,
        private readonly ProductRepositoryInterface $productRepository
    ) {}

    public function getProduct(): ?ProductInterface
    {
        try {
            return $this->productRepository->getById(
                (int) $this->request->getParam('id')
            );
        } catch (NoSuchEntityException) {
            return null;
        }
    }
}
// Injiziert via Layout-XML — kein globaler State!

<?php
// ANTI-PATTERN 2: Statischer Zugriff auf Singleton
// Alt:
class LegacyHelper
{
    public static function getConfig(string $path): ?string
    {
        return \Magento\Framework\App\ObjectManager::getInstance()
            ->get(\Magento\Framework\App\Config\ScopeConfigInterface::class)
            ->getValue($path);
    }
}

// Neu (Injectable Service):
class ConfigProvider
{
    public function __construct(
        private readonly ScopeConfigInterface $scopeConfig
    ) {}

    public function getValue(string $path, string $scope = ScopeInterface::SCOPE_STORE): ?string
    {
        return $this->scopeConfig->getValue($path, $scope);
    }
}
// Inject ConfigProvider via Constructor — testbar, explizit.

Mironsoft

Magento 2 Code-Qualität & Refactoring

Anti-Patterns aus deinem Magento-Code entfernen?

Wir analysieren bestehenden Magento-Code auf Singleton-Anti-Patterns, ObjectManager-Direktzugriffe und Registry-Abhängigkeiten – und refaktorieren auf moderne, testbare Patterns.

Code-Audit
Bestehende Module auf Anti-Patterns prüfen: ObjectManager-Direktzugriffe, Registry-Abhängigkeiten, statische Methoden und Singleton-Missbrauch.
Refactoring
Schrittweises Refactoring: Registry durch ViewModel ersetzen, ObjectManager durch Constructor Injection, statische durch injizierbare Klassen.
Test-Setup
PHPUnit-Tests für refaktorierte Klassen: Mocking-Strategie, Test-Fixtures, PHPStan-Integration und CI/CD-Pipeline.

10. Zusammenfassung

Das Singleton Pattern lebt in Magento 2 weiter – als Shared Objects im DI-Container. Das ist meist sinnvoll für zustandslose Services, aber gefährlich für zustandsbehaftete Klassen wie Models, Collections und Session-Objekte. Der Schlüssel: Constructor Injection statt ObjectManager, Factories für Non-shared Objects, und keine veränderlichen State-Properties in Shared Services.

Singleton Pattern in Magento – Überblick

Shared Objects (Default)

Services, Repositories, Config, Logger → shared ist richtig. Einmal erzeugt, für den ganzen Request wiederverwendet. Kein explizites shared="true" nötig.

Non-shared Objects

Models, Collections, Data Objects → shared="false" in di.xml. Immer über Factory erzeugen, nie direkt per new oder ObjectManager::create().

Anti-Patterns vermeiden

ObjectManager::getInstance() → verboten. Registry für Datenweitergabe → durch ViewModel ersetzen. Statische Methoden → durch Injectable Services ersetzen.

Testbarkeit

Shared Services mit Constructor Injection sind vollständig testbar: Abhängigkeiten via createMock() ersetzen. Keine ObjectManager-Aufrufe = kein Magento-Bootstrap nötig.

11. FAQ: Singleton Pattern in Magento 2

1 Ist jede Magento-Klasse automatisch ein Singleton?
Standardmäßig ja: DI-Container erstellt pro Request nur eine Instanz (shared=true). Ausnahmen: Klassen mit shared="false" (Models, Collections) und alles über Factory::create() erzeugte.
2 Wann sollte ich shared='false' in di.xml setzen?
Immer wenn die Klasse Zustand eines spezifischen Datensatzes hält: Models, Data Objects, SearchResults, Collections. Services und Repositories sind immer Shared — sie sind zustandslos.
3 Darf ich ObjectManager::getInstance() verwenden?
Nein – in eigenem Code verboten. Erlaubt nur in: Installer-Klassen, generierten Factories, Test-Bootstrap. In Services, Blocks, Controllern, ViewModels: niemals. Immer Constructor Injection verwenden.
4 Factory::create() vs. ObjectManager::create()?
Beide erzeugen neue (Non-shared) Instanzen. Unterschied: ObjectManager ist Service Locator (verboten, nicht testbar). Factory::create() ist korrekte Abstraktion: injizierbar, mockbar, automatisch generiert. Immer Factory verwenden.
5 Wie ersetze ich die alte Magento Registry?
Registry ist deprecated seit 2.3. Alternative: ViewModel – Daten direkt aus Repository holen. Oder Layout-XML Arguments: Werte direkt in Block konfigurieren. Registry war globaler Schlüssel-Wert-Speicher → ersetzen durch explizite Abhängigkeiten.
6 Wie teste ich Klassen mit Shared-Service-Abhängigkeiten?
Constructor Injection = vollständig testbar. Im PHPUnit-Test: createMock() für jede Abhängigkeit, Rückgabewerte konfigurieren, Klasse mit Mocks instanziieren. Kein Bootstrap, kein ObjectManager nötig.
7 Was passiert wenn ich Session in CLI verwende?
Session ist ein Shared Object — in CLI gibt es keine HTTP-Session → Fehler oder leere Daten. Lösung: Kundendaten in CLI direkt aus Repository laden. Wenn nötig: Session\Proxy injizieren (Lazy Loading).
8 Wie erkenne ich ob eine Klasse Shared oder Non-shared ist?
In di.xml suchen: grep -r 'shared="false"'. AbstractModel-Erben meist Non-shared. Collections immer Non-shared. Services/Repositories immer Shared. Im Zweifel: Factory verwenden.
9 Kann ich Singletons für In-Request-Caching nutzen?
Ja — ein Shared Service kann Werte in privaten Properties cachen. Da es pro Request nur eine Instanz gibt, gilt der Cache für den gesamten Request. Wichtig: nur für temporäre Request-Daten, nicht für persistente Daten.
10 Wie funktioniert das Proxy-Pattern als Alternative?
Proxy = Lazy Loading: Instanz wird erst beim ersten Methodenaufruf erzeugt. Nützlich für schwere Objekte in Services. Nutzung: statt Session::class injiziere Session\Proxy::class — Magento generiert die Proxy-Klasse automatisch.