Architecture Challenges

Magento 2 Customizations

About Me

  • Magento addict since May 2007
  • I love to take challenges
  • I love to optimize performance
  • I love to train others on those matters

Are there any Software Architects?

Are there any Senior Developers?

Are there any Junior Developers?

Every developer in this room is an architect

We are all responsible for our Software Design

Architecting software is hard...

For example in the beggining of 2007

ZF1 just went into first beta (0.8.0)

Symfony 1.0 just got released

Composer?

Unit testing was not yet a thing

Everyone was using Subversion (SVN)

I wish I knew everything I know now back then

Challenge #0: Using Magento 1.x approach

Magento 2.x is a huge step ahead and learning new concepts hard at first

But these new learning curve is going to pay off in the end

A lot of people tell that Magento 2 should have used Symfony and Doctrine...

But I am happy Magento 2.x does not use Symfony and Doctine

Symfony has an excessive configuration boilerplate


<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <parameters>
        <parameter key="doctrine.orm.configuration.class">Doctrine\ORM\Configuration</parameter>
        <parameter key="doctrine.orm.entity_manager.class">Doctrine\ORM\EntityManager</parameter>
        <parameter key="doctrine.orm.manager_configurator.class">Doctrine\Bundle\DoctrineBundle\ManagerConfigurator</parameter>

        <!-- cache -->
        <parameter key="doctrine.orm.cache.array.class">Doctrine\Common\Cache\ArrayCache</parameter>
        <parameter key="doctrine.orm.cache.apc.class">Doctrine\Common\Cache\ApcCache</parameter>
        <parameter key="doctrine.orm.cache.memcache.class">Doctrine\Common\Cache\MemcacheCache</parameter>
        <parameter key="doctrine.orm.cache.memcache_host">localhost</parameter>
        <parameter key="doctrine.orm.cache.memcache_port">11211</parameter>
        <parameter key="doctrine.orm.cache.memcache_instance.class">Memcache</parameter>
        <parameter key="doctrine.orm.cache.memcached.class">Doctrine\Common\Cache\MemcachedCache</parameter>
        <parameter key="doctrine.orm.cache.memcached_host">localhost</parameter>
        <parameter key="doctrine.orm.cache.memcached_port">11211</parameter>
        <parameter key="doctrine.orm.cache.memcached_instance.class">Memcached</parameter>
        <parameter key="doctrine.orm.cache.redis.class">Doctrine\Common\Cache\RedisCache</parameter>
        <parameter key="doctrine.orm.cache.redis_host">localhost</parameter>
        <parameter key="doctrine.orm.cache.redis_port">6379</parameter>
        <parameter key="doctrine.orm.cache.redis_instance.class">Redis</parameter>
        <parameter key="doctrine.orm.cache.xcache.class">Doctrine\Common\Cache\XcacheCache</parameter>
        <parameter key="doctrine.orm.cache.wincache.class">Doctrine\Common\Cache\WinCacheCache</parameter>
        <parameter key="doctrine.orm.cache.zenddata.class">Doctrine\Common\Cache\ZendDataCache</parameter>

        <!-- metadata -->
        <parameter key="doctrine.orm.metadata.driver_chain.class">Doctrine\Common\Persistence\Mapping\Driver\MappingDriverChain</parameter>
        <parameter key="doctrine.orm.metadata.annotation.class">Doctrine\ORM\Mapping\Driver\AnnotationDriver</parameter>
        <parameter key="doctrine.orm.metadata.xml.class">Doctrine\ORM\Mapping\Driver\SimplifiedXmlDriver</parameter>
        <parameter key="doctrine.orm.metadata.yml.class">Doctrine\ORM\Mapping\Driver\SimplifiedYamlDriver</parameter>
        <parameter key="doctrine.orm.metadata.php.class">Doctrine\ORM\Mapping\Driver\PHPDriver</parameter>
        <parameter key="doctrine.orm.metadata.staticphp.class">Doctrine\ORM\Mapping\Driver\StaticPHPDriver</parameter>
                

Doctrine is bound to its multi-vendor approach and forces code to be your data representation


/**
 * @Entity @Table(name="bugs")
 **/
class Bug
{
    /**
     * @Id @Column(type="integer") @GeneratedValue
     **/
    protected $id;
    /**
     * @Column(type="string")
     **/
    protected $description;
    /**
     * @Column(type="datetime")
     **/
    protected $created;
    /**
     * @Column(type="string")
     **/
    protected $status;

    /**
     * @ManyToOne(targetEntity="User", inversedBy="assignedBugs")
     **/
    protected $engineer;

    /**
     * @ManyToOne(targetEntity="User", inversedBy="reportedBugs")
     **/
    protected $reporter;

    /**
     * @ManyToMany(targetEntity="Product")
     **/
    protected $products;

    // ... (other code)
}
                

Focus on your domain model

Do not drive design by your framework of choice, use it as a tool.

Challenge #1: Looking at the core modules to create your own functionality

There are some new code, but mostly it is still good old Magento 1.x code that is going to change

Do not look at the core module for an example of implemenation, do not copy-paste the code

Use core code only as a reference

Read http://devdocs.magento.com/ for module structure guidelines

Challenge #2: Data model dependencies

Global Website Store (GWS)

Binding your data model to GWS leads to scaling issues of your implementation

Example

  • EU Merchant can sell to 28 member states
  • Countries might have more then one language
  • Every country might get own domain name
  • Price might differ per country

Problem

  • You might end up with 100+ store views
  • Language+Country results in data duplications in EAV structure

Avoid existing structures where possible and inverse your dependencies

Solution

  • Create own entity: Country Scope
  • Create an adapter around store view to override configuration at runtime
  • Use store view as a language and link it to scope
  • Create country access attribute on product level if needed

Challenge #3: Extending existing functionality

Rewrite (preference) is a bad thing

Plugin is not a good thing either

Plugin disadvantages

  • It is very hard to debug
  • It is global for an area
  • It can affect performance

Local Dependency override is a good thing

How To

  • Find exact dependency you need to modify for customization
  • Create a decorator that implements same interface
  • Pass it into a class via di.xml

Challenge #4: Depending on concrete implementation

Injecting concrete class from another component leads to fragile dependency


    namespace Vendor\ProductCustomization\Model;
    
    class SomeClassThatDependsOnProduct
    {
        public function doSomethingWithProduct(\Magento\Catalog\Model\Product $product)
        {
            // Bunch of code lines
            $category = $product->getCategory();
            // Bunch of other code lines
        }
    }
    

Protect your code by introducing own abstractions

How To

  • Find functionality you want to use from Magento 2 module
  • Create your own interface for that implementation
  • Create an adapter based on that interface with Magento 2 module class as dependency

Over-engeneering?

  • You have to modify only adapter in case of breaking change
  • Your code is easier to test
  • Your code is protable

You are abstracting your business logic from concerete framework implementation

It means you can re-use your business logic in Magento 1.x or any other platform

Concrete Examples

I need to export stock quantities of physical product SKUs for a specific store

So I want to use external module entity repository...

Drawbacks

  • Direct use of repository requires too much boilerplate inside of business logic
  • Request parameters are not revealing the intent
  • Performance optimization would require complete rewrite of the code

Create your own abstraction and implement Magento adapter for it

Component level abstraction


namespace Conference\StockExport\Source;

interface Entry
{
    public function getName();
    
    public function getSku();
    
    public function getQty();
}

interface EntryGateway
{
    public function getTotalPages($scopeId): int;

    public function getEntriesByPage($scopeId, $page): Entry[];
}

Adapter for entry


namespace Conference\StockExport\Source;


use Magento\Catalog\Api\Data\ProductInterface;
use Magento\CatalogInventory\Api\Data\StockStatusInterface;

class MagentoEntry implements Entry
{
    private $product;
    private $stockStatus;

    public function __construct(ProductInterface $product, StockStatusInterface $stockStatus)
    {
        $this->product = $product;
        $this->stockStatus = $stockStatus;
    }

    public function getName()
    {
        return $this->product->getName();
    }

    public function getSku()
    {
        return $this->product->getSku();
    }

    public function getQty()
    {
        return $this->stockStatus->getQty();
    }
}

Adapter for entry gateway


namespace Conference\StockExport\Source;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\CatalogInventory\Api\StockRegistryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Store\Model\StoreManagerInterface;

class MagentoEntryGateway implements EntryGateway
{
    private $searchCriteriaBuilder;

    private $stockRegistry;

    private $productRepository;

    private $storeManager;

    private $pageSize;

    private $magentoEntryFactory;

    public function __construct(
        SearchCriteriaBuilder $searchCriteriaBuilder,
        ProductRepositoryInterface $productRepository,
        StoreManagerInterface $storeManager,
        StockRegistryInterface $stockRegistry,
        MagentoEntryFactory $magentoEntryFactory
    ) {
        $this->searchCriteriaBuilder = $searchCriteriaBuilder;
        $this->productRepository = $productRepository;
        $this->storeManager = $storeManager;
        $this->stockRegistry = $stockRegistry;
        $this->pageSize = 1000;
        $this->magentoEntryFactory = $magentoEntryFactory;
    }
    // .. next slide
}

Adapter for entry gateway


namespace Conference\StockExport\Source;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\CatalogInventory\Api\StockRegistryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Store\Model\StoreManagerInterface;

class MagentoEntryGateway implements EntryGateway
{
    // .. prev slide
    public function getTotalPages($scopeId): int
    {
        $this->storeManager->setCurrentStore($scopeId);
        $searchCriteria = $this->createProductSearchCriteria(1, 1);
        $searchResult = $this->productRepository->getList($searchCriteria);

        return ceil($searchResult->getTotalCount() / $this->pageSize);
    }

    public function getEntriesByPage($scopeId, $page): \Traversable
    {
        $this->storeManager->setCurrentStore($scopeId);

        $searchCriteria = $this->createProductSearchCriteria($this->pageSize, $page);
        $searchResult = $this->productRepository->getList($searchCriteria);

        foreach ($searchResult->getItems() as $productItem) {
            yield $this->magentoEntryFactory->create([
                'product' => $productItem,
                'stockStatus' => $this->stockRegistry->getStockStatus(
                    $productItem->getId(),
                    $scopeId
                ),
            ]);
        }
    }
    // .. next slide
}

Adapter for entry gateway


namespace Conference\StockExport\Source;

use Magento\Catalog\Api\ProductRepositoryInterface;
use Magento\CatalogInventory\Api\StockRegistryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Store\Model\StoreManagerInterface;

class MagentoEntryGateway implements EntryGateway
{
    // .. prev slide
    private function createProductSearchCriteria($pageSize, $currentPage): \Magento\Framework\Api\SearchCriteria
    {
        $searchCriteria = $this->searchCriteriaBuilder
            ->addFilter('type_id', 'simple')
            ->addFilter('status', '1')
            ->setPageSize($pageSize)
            ->setCurrentPage($currentPage)
            ->create()
        ;
        return $searchCriteria;
    }
}

Somewhere in your component


namespace Conference\StockExport;

use Conference\StockExport\Source\EntryGateway;

class Export
{
    private $entryGateway;

    public function __construct(EntryGateway $entryGateway)
    {
        $this->entryGateway = $entryGateway;
    }

    public function export(\SplFileObject $file, $scopeId)
    {
        $file->fputcsv(['name', 'sku', 'qty']);

        $totalPages = $this->entryGateway->getTotalPages($scopeId);

        for ($page = 1; $page <= $totalPages; $page ++) {
            foreach ($this->entryGateway->getEntriesByPage($scopeId, $page) as $entry) {
                $file->fputcsv([$entry->getName(), $entry->getSku(), $entry->getQty()]);
            }
        }
    }
}
    

Benefits

  • EntryGateway is much easier to use as test dependency
  • It is less fragile, as Magento glue code is isolated from your code
  • Easy to implement new adapter for your component

I want to auto-complete address by postcode and house number

So I need to customize Magento behaviour

Extension Point


 namespace Magento\Checkout\Model;

class GuestShippingInformationManagement implements \Magento\Checkout\Api\GuestShippingInformationManagementInterface
{
    protected $quoteIdMaskFactory;

    protected $shippingInformationManagement;

    public function __construct(
        \Magento\Quote\Model\QuoteIdMaskFactory $quoteIdMaskFactory,
        \Magento\Checkout\Api\ShippingInformationManagementInterface $shippingInformationManagement
    ) {
        $this->quoteIdMaskFactory = $quoteIdMaskFactory;
        $this->shippingInformationManagement = $shippingInformationManagement;
    }

    public function saveAddressInformation(
        $cartId,
        \Magento\Checkout\Api\Data\ShippingInformationInterface $addressInformation
    ) {
        $quoteIdMask = $this->quoteIdMaskFactory->create()->load($cartId, 'masked_id');
        return $this->shippingInformationManagement->saveAddressInformation(
            $quoteIdMask->getQuoteId(),
            $addressInformation
        );
    }
}

Abstraction


namespace Conference\CompleteAddress\Model;

interface CompletableAddress
{
    public function isComplete(): bool;

    public function getPostcode(): string;

    public function getHouseNumber(): string;

    public function complete(string $street, string $city);
}
    			

Domain Logic


namespace Conference\CompleteAddress\Model;

class CompleteService
{
    public function completeAdddress(CompletableAddress $address)
    {
        if ($address->isComplete()) {
            return;
        }
        
        // .. do something with house number and postcode

        $address->complete($streetName, $city);
    }
}
    			

Address Adapter


namespace Conference\CompleteAddress\Model;

use Magento\Checkout\Api\Data\ShippingInformationInterface;

class MagentoCompletableAddress implements CompletableAddress
{
    private $shippingInformation;

    public function __construct(ShippingInformationInterface $shippingInformation)
    {
        $this->shippingInformation = $shippingInformation;
    }
    // ... other methods
    public function complete(string $street, string $city)
    {
        $this->getShippingAddress()->setCity($city);
        $this->getShippingAddress()->setStreet([
            $this->getShippingAddress()->getStreet()[0],
            $street
        ]);
    }

    private function getShippingAddress(): \Magento\Quote\Api\Data\AddressInterface
    {
        return $this->shippingInformation->getShippingAddress();
    }
}

Decorator


namespace Conference\CompleteAddress\Model;

use Magento\Checkout\Api\ShippingInformationManagementInterface;
use Magento\Checkout\Api\Data\ShippingInformationInterface;

class ShippingInfromationManagementDecorator implements ShippingInformationManagementInterface
{
    private $shippingInformationManagement;

    private $completableAddressFactory;

    private $completeService;

    public function __construct(
        ShippingInformationManagementInterface $shippingInformationManagement,
        MagentoCompletableAddressFactory $completableAddressFactory,
        CompleteService $completeService
    ) {
        $this->shippingInformationManagement = $shippingInformationManagement;
        $this->completableAddressFactory = $completableAddressFactory;
        $this->completeService = $completeService;
    }

    public function saveAddressInformation($cartId, ShippingInformationInterface $addressInformation)
    {
        $completableAddress = $this->completableAddressFactory->create(
            ['shippingInformation' => $addressInformation]
        );

        $this->completeService->completeAdddress($completableAddress);
        return $this->shippingInformationManagement->saveAddressInformation(
            $cartId,
            $addressInformation
        );
    }
}
    			

Configuration


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

    <type name="Magento\Checkout\Model\GuestShippingInformationManagement">
        <arguments>
            <argument
                name="shippingInformationManagement"
                xsi:type="object">
                	Conference\CompleteAddress\Model\ShippingInformationManagementDecorator
            </argument>
        </arguments>
    </type>
</config>
    			

I want to customize Magento behaviour but there is no interface

Than you can create a plugin

Plugin


namespace Conference\CompleteAddress\Model;

use Magento\Checkout\Model\ShippingInformationManagement;

class ShippingInformationManagementPlugin
{
    private $completableAddressFactory;

    private $completeService;

    public function __construct(
        MagentoCompletableAddressFactory $completableAddressFactory,
        CompleteService $completeService
    ) {
        $this->completableAddressFactory = $completableAddressFactory;
        $this->completeService = $completeService;
    }

    public function beforeSaveAddressInformation(
        ShippingInformationManagement $subject,
        $cartId,
        ShippingInformationInterface $addressInformation
    ) {
        $completableAddress = $this->completableAddressFactory->create(
            ['shippingInformation' => $addressInformation]
        );

        $this->completeService->completeAdddress($completableAddress);

        return [$cartId, $addressInformation];
    }
}
    			

Configuration


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Checkout\Model\ShippingInformationManagement">
        <plugin
                type="Conference\CompleteAddress\Model\ShippingInformationManagementPlugin"
                name="shipping_information_management_plugin" />
    </type>
</config>
    			

Summary

  • Create your own abstractions and write adapter for a framework you use
  • Use decorator for customizations and delegate to your domain model
  • Use plugins only when no interface available for decoration

Dutch Magento 2 Dev Club

Thank You

Q&A