Design Patterns in Magento 2: The Complete Overview
· Reading time: approx. 25 minutes · Categories: Magento 2, PHP, Software Architecture
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.
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?
- 2. Dependency Injection (DI)
- 3. Repository Pattern & Service Contracts
- 4. ViewModel Pattern
- 5. Plugin / Interceptor Pattern
- 6. Observer / Event Pattern
- 7. Factory Pattern
- 8. Proxy Pattern
- 9. Builder Pattern
- 10. Composite Pattern
- 11. MVC & Front Controller Pattern
- 12. Strategy Pattern
- 13. Iterator Pattern & Collections
- 14. Active Record Pattern
- 15. Preferences
- 16. Singleton & Registry (Anti-Patterns)
- 17. Service Locator & ObjectManager (Anti-Pattern)
- 18. Injectable/Non-Injectable & Virtual Types
- 19. Prototype & Object Pool Pattern
- 20. Which pattern, when?
- 21. Summary
- 22. FAQ
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:
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?
2 When should I use a preference instead of a plugin?
final and plugins cannot take effect.3 Why always inject an interface instead of a class?
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?
5 When ViewModel instead of Block?
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?
private $repo; public function __construct(RepoInterface $repo) { $this->repo = $repo; }, you simply write: public function __construct(private readonly RepoInterface $repo) {}. Less boilerplate, clearer code.