Design Patterns in Magento 2: The Complete Overview (13+ Patterns) | Mironsoft Blog
AI generated

Design Patterns in Magento 2: The Complete Overview

· Reading time: approx. 25 minutes · Categories: Magento 2, PHP, Software Architecture

{ }
DI
Magento 2 Architecture & PHP Patterns

Design Patterns in
Magento 2

DI, Repository, ViewModel, Plugin, Observer, Factory, Proxy, MVC, Strategy, Iterator, Active Record, Singleton, Registry and more: all 13+ architectural patterns in Magento 2 explained, with code examples and the knowledge of when which pattern is the right choice.

⏱ 25 min read 13+ Design Patterns PHP 8.4 ???? Complete Reference

Why design patterns are indispensable in Magento 2

Magento 2 is not simply an e-commerce framework: it is a carefully engineered software system that consistently builds on proven design patterns. Anyone developing Magento modules without knowing these patterns is constantly fighting the current: code becomes brittle, upgrades break customizations, and unit tests become nearly impossible.

Design patterns are reusable solution templates for recurring software problems. They were systematized by the so-called "Gang of Four" (Gamma, Helm, Johnson, Vlissides) in their 1994 classic. Magento 2 picks up these concepts and extends them with its own patterns, tailored specifically to the requirements of an enterprise e-commerce system.

This article gives a complete overview of all relevant design patterns in Magento 2, from fundamental ones like Dependency Injection to Magento-specific patterns like the Plugin/Interceptor Pattern. Each pattern is explained with its motivation, its implementation, and a concrete code example. Deeper coverage of individual patterns follows in the linked specialized articles.

1. What are design patterns?

Design patterns are not ready-made code libraries you simply import. They are conceptual templates: abstract descriptions of class structures and interactions that solve a specific, frequently occurring design problem. Three categories are commonly distinguished:

  • Creational Patterns: Govern how objects are created. Example: Factory, Builder, Singleton.
  • Structural Patterns: Define how classes and objects are composed. Example: Proxy, Composite, Decorator.
  • Behavioral Patterns: Govern communication between objects. Example: Observer, Command, Strategy.

Magento 2 uses all three categories, and adds the Plugin/Interceptor Pattern as its own Magento-specific pattern that exists in this form in no other framework.

2. Dependency Injection (DI)

The Dependency Injection pattern is the foundation of the entire Magento 2 architecture. Without an understanding of DI, meaningful Magento development is not possible.

The problem without DI

Without DI, classes instantiate their dependencies themselves, using new. This makes code hard to test (because dependencies cannot be swapped) and hard to extend (because dependencies are hard-coded).


<?php
// WITHOUT Dependency Injection: hard to test, tightly coupled
class OrderService
{
    public function createOrder(array $items): Order
    {
        $logger = new \Monolog\Logger('order');  // Hard-coded
        $mailer = new \App\Mail\OrderMailer();   // Hard-coded
        $repository = new \App\Repository\OrderRepository(); // Hard-coded

        // If OrderRepository needs to change, this class must be touched
        $order = $repository->save($items);
        $mailer->sendConfirmation($order);
        $logger->info('Order created', ['id' => $order->getId()]);
        return $order;
    }
}

Dependency Injection in Magento 2

In Magento 2, all dependencies are injected via the constructor. The Magento ObjectManager (the dependency injection container) reads the type hints in the constructor parameters and automatically instantiates the required objects. Since PHP 8.0, Magento uses Constructor Property Promotion for this.


<?php
declare(strict_types=1);

namespace Mironsoft\Orders\Model;

use Psr\Log\LoggerInterface;
use Mironsoft\Orders\Api\OrderRepositoryInterface;
use Mironsoft\Orders\Api\Data\OrderInterface;
use Mironsoft\Orders\Mail\OrderMailerInterface;

class OrderService
{
    /**
     * Constructor with dependency injection via constructor property promotion (PHP 8.0+)
     */
    public function __construct(
        private readonly LoggerInterface $logger,
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly OrderMailerInterface $orderMailer
    ) {}

    /**
     * Creates a new order from the given items.
     */
    public function createOrder(array $items): OrderInterface
    {
        $order = $this->orderRepository->save($items);
        $this->orderMailer->sendConfirmation($order);
        $this->logger->info('Order created', ['id' => $order->getId()]);
        return $order;
    }
}

The configuration of which class is used for an interface happens in di.xml:


<!-- app/code/Mironsoft/Orders/etc/di.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">

    <!-- Bind Interface → concrete implementation -->
    <preference for="Mironsoft\Orders\Api\OrderRepositoryInterface"
                type="Mironsoft\Orders\Model\OrderRepository"/>

    <preference for="Mironsoft\Orders\Mail\OrderMailerInterface"
                type="Mironsoft\Orders\Mail\OrderMailer"/>
</config>

Benefits: Testable (mock implementations can be injected), swappable (different implementation via di.xml), extensible (without core changes). DI is the backbone of Magento: almost every other pattern builds on it.

→ Deep dive: Dependency Injection in Magento 2

3. Repository Pattern & Service Contracts

The Repository pattern encapsulates data access behind a stable interface. In Magento 2, repositories are defined as part of Service Contracts, one of the most important architectural principles of Magento 2.0+.

Service Contracts: the promise of stable APIs

Service Contracts are PHP interfaces under the Api/ namespace of a module. They define the public API of a module and guarantee backward compatibility across upgrades. No one should instantiate a concrete Magento class directly; instead, you inject the interface.


<?php
declare(strict_types=1);

// API Interface (the stable contract, Service Contract)
namespace Mironsoft\Catalog\Api;

use Magento\Framework\Api\SearchCriteriaInterface;
use Mironsoft\Catalog\Api\Data\ProductInterface;
use Mironsoft\Catalog\Api\Data\ProductSearchResultsInterface;

interface ProductRepositoryInterface
{
    /** Retrieves a product by its ID. */
    public function getById(int $id, bool $editMode = false): ProductInterface;

    /** Retrieves a product by its SKU. */
    public function get(string $sku): ProductInterface;

    /** Saves a product and returns the saved instance. */
    public function save(ProductInterface $product): ProductInterface;

    /** Deletes a product. */
    public function delete(ProductInterface $product): bool;

    /** Retrieves a list of products based on search criteria. */
    public function getList(SearchCriteriaInterface $searchCriteria): ProductSearchResultsInterface;
}

The concrete implementation in Model/ProductRepository.php contains the actual database logic. Classes that need products inject only the interface, never the implementation directly.


<?php
// Usage: always inject the interface!
use Mironsoft\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;

class ProductService
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder
    ) {}

    /** Returns all active products in a specific category. */
    public function getActiveProductsByCategoryId(int $categoryId): array
    {
        $searchCriteria = $this->searchCriteriaBuilder
            ->addFilter('category_id', $categoryId)
            ->addFilter('status', 1)
            ->create();

        return $this->productRepository->getList($searchCriteria)->getItems();
    }
}

Benefits: A clear data access layer, testable with mocks, a stable API across upgrades, and REST/GraphQL exposure possible.

→ Deep dive: Repository Pattern & Service Contracts

4. ViewModel Pattern

The ViewModel pattern is the modern answer to the problem of bloated Magento block classes. Instead of stuffing all presentation logic into block classes that are deeply rooted in the Magento class hierarchy, you use lightweight ViewModels.

The problem with block classes

Block classes in Magento inherit from Magento\Framework\View\Element\Template, which in turn inherits from Magento\Framework\View\Element\AbstractBlock, a very complex base class with caching, layout references, and render logic. Custom logic in blocks is hard to test and tightly bound to the Magento framework.


<?php
declare(strict_types=1);

// ViewModel: implements ArgumentInterface, NO Magento base class needed
namespace Mironsoft\Catalog\ViewModel;

use Magento\Framework\View\Element\Block\ArgumentInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\Framework\Pricing\Helper\Data as PricingHelper;

class ProductBadge implements ArgumentInterface
{
    public function __construct(
        private readonly ProductRepositoryInterface $productRepository,
        private readonly PricingHelper $pricingHelper
    ) {}

    /**
     * Returns the discount percentage for a product.
     */
    public function getDiscountPercent(int $productId): int
    {
        $product = $this->productRepository->getById($productId);
        $regularPrice = (float) $product->getPriceInfo()
            ->getPrice('regular_price')->getValue();
        $finalPrice = (float) $product->getPriceInfo()
            ->getPrice('final_price')->getValue();

        if ($regularPrice <= 0) {
            return 0;
        }

        return (int) round((1 - $finalPrice / $regularPrice) * 100);
    }

    /**
     * Formats a price according to the current store's locale.
     */
    public function formatPrice(float $price): string
    {
        return $this->pricingHelper->currency($price, true, false);
    }
}

The ViewModel is passed to the block as an argument via layout XML and used in the template:


<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Mironsoft\Catalog\ViewModel\ProductBadge $viewModel */
$viewModel = $block->getData('view_model');
$product = $block->getProduct();
$discount = $viewModel->getDiscountPercent((int) $product->getId());
?>
<?php if ($discount > 0): ?>
<span class="badge-sale bg-red-500 text-white px-2 py-1 rounded text-xs font-bold">
    -<?= $discount ?>%
</span>
<?php endif; ?>

Benefits: Easily unit-testable, no dependency on Magento base classes, a clean separation of view logic and rendering, easily swappable via di.xml.

→ Deep dive: ViewModel Pattern in Magento 2

5. Plugin / Interceptor Pattern

The Plugin pattern is Magento's answer to a classic problem: how can you change the behavior of an existing class without editing that class directly? This is especially critical in the context of third-party modules and core extensions.

The three plugin types

Magento supports three kinds of plugins, all registered via di.xml:

  • Before Plugin: Runs before the original method. Can modify the input parameters.
  • After Plugin: Runs after the original method. Can modify the return value.
  • Around Plugin: Wraps the original method completely. Decides whether and how the original method is called.

<?php
declare(strict_types=1);

namespace Mironsoft\Catalog\Plugin;

use Magento\Catalog\Model\Product;
use Magento\Catalog\Api\ProductRepositoryInterface;

/**
 * Plugin to add custom stock badge data when loading products.
 */
class ProductStockBadgePlugin
{
    /**
     * After-Plugin: adds badge data to the returned product.
     * Called after ProductRepository::getById() completes.
     */
    public function afterGetById(
        ProductRepositoryInterface $subject,
        Product $result,
        int $productId
    ): Product {
        // Enrich the product with custom badge information
        $result->setData('has_sale_badge', $result->getSpecialPrice() > 0);
        return $result;
    }

    /**
     * Before-Plugin: logs every product load for analytics.
     * Called before ProductRepository::get() executes.
     *
     * @return array modified arguments
     */
    public function beforeGet(
        ProductRepositoryInterface $subject,
        string $sku
    ): array {
        // Normalize SKU to uppercase before any lookup
        return [strtoupper($sku)];
    }
}

<!-- di.xml: register the plugin -->
<config>
    <type name="Magento\Catalog\Api\ProductRepositoryInterface">
        <plugin name="mironsoft_catalog_product_stock_badge"
                type="Mironsoft\Catalog\Plugin\ProductStockBadgePlugin"
                sortOrder="10"
                disabled="false"/>
    </type>
</config>

Benefits: No core override needed, multiple plugins can coexist, upgrade-safe, and cleanly disableable.

Important: Around plugins should be used sparingly, since they affect the entire plugin chain. Before and after plugins are usually sufficient.

→ Deep dive: Plugin / Interceptor Pattern in Magento 2

6. Observer / Event Pattern

The Observer pattern (also known as the Publisher-Subscriber pattern) enables loose coupling between modules. A module dispatches an event without knowing who reacts to it. Other modules can register observers that respond to this event.


<?php
declare(strict_types=1);

// Dispatching an event
namespace Mironsoft\Orders\Model;

use Magento\Framework\Event\ManagerInterface as EventManager;

class OrderService
{
    public function __construct(
        private readonly EventManager $eventManager
    ) {}

    public function cancelOrder(int $orderId): void
    {
        // ... cancel logic ...

        // Dispatch event: other modules can react without coupling
        $this->eventManager->dispatch(
            'mironsoft_order_cancelled',
            ['order_id' => $orderId, 'reason' => 'customer_request']
        );
    }
}

<?php
declare(strict_types=1);

// Observer in another module
namespace Mironsoft\Notifications\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;

class SendCancellationEmail implements ObserverInterface
{
    public function __construct(
        private readonly \Mironsoft\Notifications\Mail\OrderMailer $mailer
    ) {}

    /**
     * Sends a cancellation email when an order is cancelled.
     */
    public function execute(Observer $observer): void
    {
        $orderId = (int) $observer->getData('order_id');
        $this->mailer->sendCancellationNotice($orderId);
    }
}

<!-- events.xml: register the observer -->
<config>
    <event name="mironsoft_order_cancelled">
        <observer name="mironsoft_send_cancellation_email"
                  instance="Mironsoft\Notifications\Observer\SendCancellationEmail"/>
    </event>
</config>

Benefits: Complete decoupling between modules, multiple observers per event, easily disableable, no need to know the recipient.

Plugin vs. Observer: Plugins modify method behavior. Observers react to events. Both extend without core changes, but for different scenarios.

→ Deep dive: Observer / Event Pattern in Magento 2

7. Factory Pattern

The Factory pattern delegates object creation to a dedicated factory class. In Magento 2, factories are automatically generated by the code generator: you generally don't need to write them yourself.


<?php
declare(strict_types=1);

namespace Mironsoft\Orders\Model;

use Mironsoft\Orders\Api\Data\OrderInterface;
use Mironsoft\Orders\Api\Data\OrderInterfaceFactory;

class OrderBuilder
{
    public function __construct(
        // Factory is auto-generated by Magento's code generator
        // Convention: InterfaceName + 'Factory'
        private readonly OrderInterfaceFactory $orderFactory
    ) {}

    /**
     * Creates a new Order instance with default values.
     * Factory ensures correct DI-configured class is instantiated.
     */
    public function createFromCartData(array $cartData): OrderInterface
    {
        /** @var OrderInterface $order */
        $order = $this->orderFactory->create();
        $order->setCustomerId((int) $cartData['customer_id']);
        $order->setStatus('pending');
        $order->setGrandTotal((float) $cartData['total']);

        return $order;
    }
}

Why not instantiate directly with new? Because that bypasses the entire DI configuration (preferences, plugins, the shared flag). Factories respect the complete ObjectManager configuration.

8. Proxy Pattern

Proxies in Magento 2 are lazy-loading wrappers. They are used when a class has an expensive dependency that is only needed in certain cases. Instead of instantiating the dependency immediately, you inject a proxy that creates the real object only when needed.


<!-- di.xml: proxy for a slow-starting service -->
<config>
    <type name="Mironsoft\Catalog\Model\PriceCalculator">
        <arguments>
            <!-- Inject the Proxy instead of the real class -->
            <!-- Magento auto-generates Mironsoft\Catalog\Model\HeavyIndexer\Proxy -->
            <argument name="indexer" xsi:type="object">
                Mironsoft\Catalog\Model\HeavyIndexer\Proxy
            </argument>
        </arguments>
    </type>
</config>

<?php
// PriceCalculator uses HeavyIndexer only for some operations.
// With Proxy: HeavyIndexer is NOT instantiated on every page load,
// only when calculateSpecialPrice() is actually called.
class PriceCalculator
{
    public function __construct(
        private readonly HeavyIndexerInterface $indexer // Proxy injected via di.xml
    ) {}

    public function calculateBasePrice(float $price): float
    {
        // HeavyIndexer never instantiated here
        return $price * 1.19;
    }

    public function calculateSpecialPrice(float $price): float
    {
        // HeavyIndexer instantiated NOW on first call to the proxy
        return $this->indexer->getSpecialFactor() * $price;
    }
}

Use cases: Heavy services that must be injected on every page but are only used on specific pages. Typical examples: customer session, catalog rule indexer, full page cache flusher.

9. Builder Pattern

The Builder pattern constructs complex objects step by step. In Magento 2, it is most commonly used for SearchCriteria objects, the search requests for repositories.


<?php
declare(strict_types=1);

use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Api\FilterBuilder;
use Magento\Framework\Api\SortOrderBuilder;

class ProductListService
{
    public function __construct(
        private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
        private readonly FilterBuilder $filterBuilder,
        private readonly SortOrderBuilder $sortOrderBuilder
    ) {}

    /**
     * Builds a complex search criteria using the Builder pattern.
     */
    public function buildSearchCriteria(
        int $categoryId,
        float $minPrice,
        float $maxPrice,
        int $page = 1,
        int $pageSize = 20
    ): \Magento\Framework\Api\SearchCriteriaInterface {

        // Each builder call adds to the criteria step by step
        $this->searchCriteriaBuilder
            ->addFilter('category_id', $categoryId)
            ->addFilter('price', $minPrice, 'gteq')    // >= minPrice
            ->addFilter('price', $maxPrice, 'lteq')    // <= maxPrice
            ->addFilter('status', 1)                    // enabled only
            ->addFilter('visibility', [3, 4], 'in');    // visible in catalog

        // Sort order: price ascending
        $sortOrder = $this->sortOrderBuilder
            ->setField('price')
            ->setDirection('ASC')
            ->create();

        $this->searchCriteriaBuilder
            ->addSortOrder($sortOrder)
            ->setCurrentPage($page)
            ->setPageSize($pageSize);

        return $this->searchCriteriaBuilder->create();
    }
}

10. Composite Pattern

The Composite pattern treats individual objects and compositions of objects uniformly. In Magento 2, the layout system is a classic example: both single blocks and containers of blocks can be rendered, because both implement the same render() interface.


<?php
// Simplified: Magento's Layout system implements Composite.
// Both a single Block and a Container of Blocks are "renderable".

// Usage in templates: always the same interface:
echo $block->getChildHtml('child.block.name');       // Single child block
echo $block->getChildHtml();                          // All children
echo $block->getChildHtml('container.with.children'); // Container = Composite

// Result: a container renders all its children automatically,
// the caller doesn't need to know if it's a leaf or a composite.

Further composite structures in Magento: config merge trees, UI Components, pricing models, and sales rule conditions.

11. MVC & Front Controller Pattern

Magento 2 is based on the MVC pattern, but not in its classic form. The controller does not know the view directly. Instead, it returns a ResultInterface object that defines which layout configuration is rendered. Providing the data is handled by block classes or ViewModels.

The Front Controller pattern ensures that all HTTP requests pass through a single entry point: pub/index.php. From there, Magento\Framework\App\FrontController handles dispatching through several routers (Base, CMS, URL-Rewrite, Default) in a defined sortOrder.


// Action controller: thin, returns only a Result
class Index implements HttpGetActionInterface
{
    public function __construct(
        private readonly PageFactory $pageFactory
    ) {}

    public function execute(): Page
    {
        return $this->pageFactory->create();
    }
}

The layout handle results from: {routeId}_{controllerFolder}_{actionName}.

→ Deep dive: MVC & Front Controller Pattern

12. Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one separately, and makes them interchangeable. The context only knows the interface, not the concrete implementation.

In Magento 2, the Strategy pattern is present in every shipping carrier (AbstractCarrier), every payment method, and every price calculation. Each carrier implements collectRates() according to its own logic.

For your own strategies: define an interface, create implementations, inject them as a pool in di.xml. Adding a new strategy means a new class plus a di.xml entry, no existing code is changed (Open/Closed Principle).

→ Deep dive: Strategy Pattern in Magento 2

13. Iterator Pattern & Collections

The Iterator pattern provides a uniform interface for iterating over a collection of objects, without knowing the internal structure. In Magento 2, AbstractCollection implements the IteratorAggregate interface, which enables direct foreach iteration.

Collections are lazy: the SQL query is only executed on first data access. For flat tables: addFieldToFilter(). For EAV attributes: addAttributeToFilter().


$products = $this->collectionFactory->create()
    ->addAttributeToSelect(['name', 'price'])
    ->addAttributeToFilter('status', ['eq' => 1])
    ->setOrder('name', 'ASC')
    ->setPageSize(20);

foreach ($products as $product) { // SQL query happens here
    echo $product->getName();
}

→ Deep dive: Iterator Pattern & Collections

14. Active Record Pattern

The Active Record pattern combines a domain object and database access in a single class. In Magento 2, this is AbstractModel: every model class has load(), save(), and delete() directly on the object.

Important: load() has been marked @deprecated since Magento 2.4.x. For new modules, use the Repository pattern. Magic getters/setters (getName(), setTitle()) come from DataObject and are not type-safe; Data Interfaces are the better alternative.

→ Deep dive: Active Record Pattern in Magento 2

15. Preferences

Preferences are the strongest intervention in the Magento DI container: an entire class is replaced system-wide by another. Defined in di.xml with <preference for="..." type="..."/>.

The most common and most legitimate use case: interface preferences that register a concrete implementation for an interface. For concrete classes, the rule is: use a preference only when new methods are needed that a plugin cannot add. Otherwise prefer plugins: plugins coexist, preferences collide.

→ Deep dive: Preferences in Magento 2

16. Singleton & Registry Pattern (Anti-Patterns)

The Singleton pattern ensures that only one instance of a class exists. In Magento 2, this is implemented via shared instances in the DI container, which is acceptable for stateless services. It becomes problematic with mutable global state: tests interfere with each other, and async processes share state.

The Registry pattern (Magento\Framework\Registry) is a global key-value store, carried over from Magento 1 and marked @deprecated since 2.4.x. Do not use it in new modules. Alternatives: constructor injection, ViewModel, event data.

→ Deep dive: Singleton & Registry (Anti-Patterns)

17. Service Locator & ObjectManager (Anti-Pattern)

The Service Locator pattern actively requests dependencies from a central registry, instead of receiving them via the constructor. In Magento 2, this is the ObjectManager.

Calling the ObjectManager directly in business logic is forbidden: it bypasses plugins, preferences, and static analysis. Exceptions: bootstrap code, generated factories, integration tests. PHPCS with the Magento2 standard automatically detects violations.

→ Deep dive: Service Locator & ObjectManager Anti-Pattern

18. Injectable/Non-Injectable & Virtual Types

Injectable objects are stateless services without a $data parameter in the constructor: repositories, loggers, factories. They are managed as shared instances and injected directly.

Non-injectable objects have mutable state (models, DTOs, collections). They must not be injected directly; instead, use a factory (PostFactory creates a new Post instance each time).

Virtual Types enable new class configurations without PHP code: register a class with different constructor arguments in di.xml. A typical example: a module-specific logger.

→ Deep dive: Injectable/Non-Injectable & Virtual Types

19. Prototype & Object Pool Pattern

The Prototype pattern creates new objects by cloning an existing prototype. In Magento 2, this appears with product types (each type is a prototype) and quote items (child items as a clone of the parent item).

The Object Pool pattern keeps a supply of pre-instantiated, reusable objects ready. Magento uses it implicitly via shared instances in the DI container, and explicitly via ResourceConnection for database connections.

→ Deep dive: Prototype & Object Pool Pattern

20. Which pattern, when?

Deciding which pattern is the right one for which problem is one of the most important skills of a Magento architect. Here is a decision guide:

Which design pattern in Magento 2, and when? Pattern Use when … Not suitable when … Dependency Injection Classes need dependencies (always use it!) Repository Pattern CRUD operations for entities Complex multi-entity queries (→ Resource Models) ViewModel Pattern Encapsulate logic for phtml templates Heavy render logic (→ Block stays) Plugin / Interceptor Change/extend a method's behavior On final classes, private methods Observer / Event Decoupled reaction to events When the return value needs to be modified Factory Pattern Create new objects (not singletons) When a singleton is enough (→ direct injection) Proxy Pattern Expensive class only occasionally needed Class is always needed (no benefit) Builder Pattern Build complex objects step by step Simple objects (→ direct construction) Composite Pattern Treat trees/hierarchies uniformly Flat structures without hierarchy

Mironsoft

Magento 2 Architecture & Development

Want to implement Magento 2 architecture professionally?

We build clean, upgrade-safe Magento 2 modules using the right design patterns: code reviews, architecture consulting, and module development following best practices.

Module Development

Clean modules with DI, Service Contracts, ViewModels, and tests

Code Review

Analyzing existing code and applying patterns correctly

Upgrade Safety

Code without core overrides, plugins instead of preferences

21. Summary

Design patterns in Magento 2 are not academic concepts: they are the daily toolbox of every professional Magento developer. The foundation is Dependency Injection, on which all other patterns build. Service Contracts and Repositories define stable APIs. ViewModels keep templates clean. Plugins extend behavior without touching the core. Observers decouple modules completely.

Design Patterns in Magento 2: the essentials at a glance

Foundation: Dependency Injection

Inject everything via the constructor. Inject interfaces, not implementations. Use Constructor Property Promotion. Use factories for new objects.

Extension: Plugin before Observer

Plugins when method behavior should change. Observers when reacting to events, without a return value. Use around plugins sparingly.

Data access: Repository Pattern

Service Contracts for public APIs. Repositories for CRUD. SearchCriteriaBuilder with FilterBuilder for search requests. Inject interfaces, never implementations.

Templates: ViewModel Pattern

Move logic out of templates into ViewModels. Implement ArgumentInterface. Pass it as an argument via layout XML. Easily unit-testable, no Magento framework needed.

22. FAQ: Design Patterns in Magento 2

1 Plugin vs. Observer: what is the difference?
Plugins attach to specific methods and can modify input/output. Observers react to events but cannot change return values. Plugins for method modification, observers for decoupled event reactions between modules.
2 When should I use a preference instead of a plugin?
Almost never. Preferences completely override a class and lead to upgrade conflicts. Plugins are the right choice in 95% of cases. Preferences only when the target class is final and plugins cannot take effect.
3 Why always inject an interface instead of a class?
Interface injection decouples code from its implementation. Via di.xml, a different implementation can be configured without changing the injecting class. This makes code testable (mocks) and swappable.
4 Factory vs. Proxy: what is the difference?
Factory: creates a new instance on every call (non-shared). Proxy: a lazy-loading wrapper that delays instantiation until the first real access. Factory for new objects, proxy for performance optimization with expensive singletons.
5 When ViewModel instead of Block?
Whenever logic should be encapsulated for a template. Implement ArgumentInterface, no Magento base class needed. Easily unit-testable. Block only when Magento-specific render functions are required (getCacheKeyInfo, etc.).
6 What is Constructor Property Promotion in Magento 2?
A PHP 8.0+ feature: it combines parameter declaration and property assignment in the constructor. Instead of private $repo; public function __construct(RepoInterface $repo) { $this->repo = $repo; }, you simply write: public function __construct(private readonly RepoInterface $repo) {}. Less boilerplate, clearer code.