Using di.xml to improve your code quality

Magento 2, Dependency Injection, di.xml, Coding tips

A while ago i developed a technique that i quite regular use in Magento 2 modules i write. The basics is quite simple: You write a container that gets a number of subclasses as a argument. This container loops over these subclasses and execute them all. Now this sounds a bit vague and abstract, so let's get use an example.

Building an export

So let's imagine you have to create an export of all simple products in this Magento 2 store. Most data are just attributes, but some values has to be calculated/mutated to fit the export format. How would you create something like that, and keep the code maintainable?

In my early days as a programmer i would have done something like this:

class Export {
    public function export($products)
    {
        $output = '';
        foreach ($products as $product) {
            $output .= $product->getName() . ';' . $product->getPrice() . ';' . $this->formatAttribute($product) . PHP_EOL;
        }

        return $output;
    }
}

Later i would move the logic to different classes, looking at something like this:

class Export {
    public function __construct(
        Exporter\ProductName $productName,
        Exporter\ProductPrice $productPrice,
        Exporter\SpecialAttribute $specialAttribute
    ) {
        $this->productName = $productName;
        $this->productPrice = $productPrice;
        $this->specialAttribute = $specialAttribute;
    }

    public function export($products)
    {
        $output = [];
        foreach ($products as $product) {
            $output[] = [
                $this->productName->export($product),
                $this->productPrice->export($product),
                $this->specialAttribute->export($product),
            ];
        }

        return $output;
    }
}

Now that's already way better. This can be applied to any framework. Let's take it one step further.

MageTested.com (ad)

Do you want your Magento store to be more reliable? Tired of things that suddenly break the checkout process without anyone noticing? You can hire my services to kickstart End-2-End testing for your Magento store. This way you know for sure that your store is behaving as expected before releasing that new feature or that update.

View MageTested.com for more information.

Throw Magento into the mix

Magento has a few tools for us that allows us to improve this code way more. First we create a contract/interface:

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Framework\DataObject;

interface ExporterInterface
{
    /**
     * @param ProductInterface $product
     * @param DataObject $row
    **/
    public function export(ProductInterface $product, DataObject $row);
}

Next, create one or more implementations of this interface:

use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Model\Product\Visibility;
use Magento\Framework\DataObject;

class ExportIsVisibility implements Exporter
{
    /**
     * @param ProductInterface $product
     * @param DataObject $row
    **/
    public function export(ProductInterface $product, DataObject $row)
    {
        $visibilities = [
            Visibility::VISIBILITY_NOT_VISIBLE => __('Not Visible Individually'),
            Visibility::VISIBILITY_IN_CATALOG => __('Catalog'),
            Visibility::VISIBILITY_IN_SEARCH => __('Search'),
            Visibility::VISIBILITY_BOTH => __('Catalog, Search'),
        ];

        $row->setData('visibility', $visibilities[$product->getVisibility()]);
    }
}

Now create a specific class for every column you want to export. Next we need a class to call all our export classes:

class Export
{
    /**
     * @var ExportInterface[]
    **/
    private $exporters;

    public function __construct(
        array $exporters
    ) {
        $this->exporters = $exporters;
    }

    public function exportRow(ProductInterface $product, DataObject $row)
    {
        foreach ($this->exporters as $exporter) {
            $exporter->export($product, $row);
        }
    }
}

As a last step we need to configure our Export class using the di.xml:

<type name="{VENDOR}\{NAMESPACE}\Service\Export">
    <arguments>
        <argument name="exporters" xsi:type="array">
            <item name="visibility" xsi:type="object">{VENDOR}\{NAMESPACE}\Service\Exporter\ExportIsVisibility</item>
            <item name="second_column" xsi:type="object">{VENDOR}\{NAMESPACE}\Service\Exporter\SecondColumn</item>
            <item name="second_column" xsi:type="object">{VENDOR}\{NAMESPACE}\Service\Exporter\ThirdColumn</item>
        </argument>
    </arguments>
</type>

Done! We now have a basic system in place for exporting data from our system.

What did we just create?

Let's take a moment to overview what we just build. This is basically the structure for something that works like this:

  • Retrieve a selection of products

  • Loop over the products 

  • Call ExportRow of the Exporter class

The Exporter class is where the magic happens. For every row, in our case a ProductInterface, it will call all configured exporters one by one. Each exporter will do it's thing so that we end with a complete export for this specific product.

At first this may seen like overkill, a class per column. Some classes do nothing more than returning $product->getSku(). But if you take some more advanced columns this makes way more sense. If you want to export if a product is in a certain category for example.

There are a few advantages to this approach:

  • These are small classes, so easy to understand.

  • Easy to understand also means easy to test (yay!).

  • Easy to understand also means easy to debug (double yay!).

Ok, but where does Magento comes in?

Well, if you followed the code samples above and used the di.xml approach, then you added something very powerful: Your exporter is easy expandable. Now when someone wants to add a column from another module, they just use the di.xml of their own module:

<type name="{VENDOR}\{NAMESPACE}\Service\Export">
    <arguments>
        <argument name="exporters" xsi:type="array">
            <item name="visibility" xsi:type="object">{OTHER-VENDOR}\{NAMESPACE}\Service\Exporter\ProductCategories</item>
        </argument>
    </arguments>
</type>

And that's it. You just unleashed the power of Magento's Dependency Injection on your module! When this second module is active the column is automatically added to your modules exporter. How cool is that?

Using the data

As a final step: How would you use this Exporter class in a real world project? It would be something like this:

class ProductExportController extends Action
{
    /**
     * @var Exporter
     */
    private $exporter;

    public function __construct(
        Exporter $exporter
        FileExportInterface $fileExport
    ) {
        $this->exporter = $exporter;
    }

    public function execute()
    {
        $collection = $this->getProductCollection();

        $rows = [];
        foreach ($collection as $product) {
            $row = new DataObject;
            $this->exporter->exportRow($product, $row);
            $rows[] = $row;
        }

        return $this->fileExport->generateResponse($rows);
    }
}

See how that turns out? We got something as complicated down to only a 8 effective lines. This is easy understandable and testable code. Also in this case i use and FileExportInterface, effectively allowing to configure the way the export is actually formatted, in JSON of XML for example. 

Want to respond?