Writing Magento 2 modules that don't break stores, don't slow down performance, and survive upgrades requires discipline. After working on production Magento codebases, here are the patterns that matter most.
Service contracts: the right way to expose functionality
Never depend directly on concrete model classes. Always use interfaces (service contracts):
// Bad: depends on a concrete class
public function __construct(
\Magento\Catalog\Model\Product $product
) {}
// Good: depends on an interface
public function __construct(
\Magento\Catalog\Api\ProductRepositoryInterface $productRepository
) {}
Why it matters: concrete models can be replaced or overridden in customized Magento installations. Depending on interfaces makes your module resilient to those changes.
If your module exposes its own functionality, define interfaces first:
// Api/MyModuleServiceInterface.php
interface MyModuleServiceInterface
{
public function process(int $entityId): ResultInterface;
}
// Model/MyModuleService.php
class MyModuleService implements MyModuleServiceInterface
{
public function process(int $entityId): ResultInterface { ... }
}
Plugins vs. observers: choosing correctly
Both plugins and observers let you hook into Magento's core behavior. Choosing wrong creates bugs and performance problems.
Use observers when:
- You're reacting to something that already happened (after event)
- Multiple modules might observe the same event (no conflict)
- You don't need to modify the return value
// Good observer usage: react to order placement
public function execute(\Magento\Framework\Event\Observer $observer): void
{
$order = $observer->getOrder();
$this->notificationService->notifyNewOrder($order->getId());
}
Use plugins when:
- You need to modify input parameters (before plugin)
- You need to modify the return value (after plugin)
- You need to wrap execution (around plugin)
// Good plugin usage: add data to a repository result
public function afterGetById(
ProductRepositoryInterface $subject,
ProductInterface $result,
int $productId
): ProductInterface {
$result->setCustomData($this->getCustomData($productId));
return $result;
}
Avoid around plugins unless necessary. Around plugins intercept the entire method call and are significantly slower than before/after plugins. They also make debugging harder.
Dependency injection done right
Magento's DI system is powerful but easy to misuse.
Use constructor injection for required dependencies:
public function __construct(
private readonly LoggerInterface $logger,
private readonly CacheInterface $cache,
private readonly ProductRepositoryInterface $productRepository,
) {}
Use factories for objects you create on-the-fly:
public function __construct(
private readonly \Magento\Catalog\Model\ProductFactory $productFactory,
) {}
public function createProduct(): Product
{
return $this->productFactory->create();
}
Inject the ObjectManager directly: never. It bypasses DI, makes testing impossible, and creates hidden dependencies.
Performance-conscious coding
Avoid loading product/order collections in loops:
// Bad: O(n) queries
foreach ($orderIds as $orderId) {
$order = $this->orderRepository->get($orderId); // query per iteration
$this->process($order);
}
// Good: load all at once
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('entity_id', $orderIds, 'in')
->create();
$orders = $this->orderRepository->getList($searchCriteria);
foreach ($orders->getItems() as $order) {
$this->process($order);
}
Cache expensive operations:
public function getExpensiveData(int $id): array
{
$cacheKey = 'my_module_data_' . $id;
$cached = $this->cache->load($cacheKey);
if ($cached !== false) {
return unserialize($cached);
}
$data = $this->runExpensiveCalculation($id);
$this->cache->save(serialize($data), $cacheKey, ['my_module_tag'], 3600);
return $data;
}
Use indexers for frequently-queried derived data:
If you're computing something on every page load that could be pre-computed (like product scores, custom pricing, or aggregated stats), build an indexer. Compute once, read many times.
Testing your module
Untested Magento modules break in production. At minimum:
Unit tests for business logic:
class MyServiceTest extends TestCase
{
public function testProcessReturnsSuccessResult(): void
{
$service = new MyService(
$this->createMock(LoggerInterface::class),
$this->createMock(CacheInterface::class),
);
$result = $service->process(1);
$this->assertTrue($result->isSuccess());
}
}
Integration tests for database operations and DI-heavy code:
class MyRepositoryTest extends \Magento\TestFramework\TestCase\AbstractController
{
public function testSaveAndLoad(): void
{
$repository = $this->_objectManager->get(MyRepositoryInterface::class);
// ...
}
}
Aim for >60% test coverage on your business logic. Models and repositories are the most important to test.
Schema and data patches over upgrade scripts
Old-style UpgradeSchema.php and UpgradeData.php scripts are deprecated. Use declarative schema and data patches:
<!-- etc/db_schema.xml -->
<table name="my_module_entity" resource="default" engine="innodb">
<column name="entity_id" xsi:type="int" unsigned="true" nullable="false" identity="true"/>
<column name="name" xsi:type="varchar" length="255" nullable="false"/>
<column name="created_at" xsi:type="timestamp" default="CURRENT_TIMESTAMP"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="entity_id"/>
</constraint>
</table>
// Setup/Patch/Data/AddDefaultConfiguration.php
class AddDefaultConfiguration implements DataPatchInterface
{
public function apply(): void
{
$this->configWriter->save('my_module/general/enabled', '1');
}
public static function getDependencies(): array { return []; }
public function getAliases(): array { return []; }
}
Patches run once and are tracked in patch_list table. No more version comparison logic.
Module structure that scales
app/code/Vendor/ModuleName/
├── Api/ # Service contract interfaces
│ └── Data/ # Data object interfaces
├── Block/ # View models and blocks
├── Console/ # CLI commands
├── Controller/ # HTTP controllers
├── Cron/ # Scheduled jobs
├── Model/ # Business logic implementations
│ └── ResourceModel/ # Database layer
├── Observer/ # Event observers
├── Plugin/ # Interceptors
├── Setup/ # Schema + data patches
├── Test/ # Unit + integration tests
├── Ui/ # UI components
├── etc/ # Configuration
│ ├── adminhtml/ # Admin-specific config
│ ├── frontend/ # Frontend-specific config
│ ├── db_schema.xml # Declarative schema
│ ├── di.xml # DI configuration
│ ├── events.xml # Event observers
│ └── module.xml # Module declaration
├── view/ # Templates, layouts, JS, CSS
├── composer.json
└── registration.php
This structure is predictable for any Magento developer joining your project.
Staying upgrade-safe
- Prefer
preferenceindi.xmlas a last resort; use plugins when possible - Don't override core templates unless absolutely necessary
- Test against the next minor Magento version in CI before it's released
- Follow Magento's deprecation notices in each release's changelogs
Originally published on magevanta.com
Top comments (0)