Lately I’ve been advocating decoupling business logic from the framework (i.e. Magento) a lot.
This has multiple advantages:
- Benefit from test driven development (TDD) without having to mock a bunch of core classes.
- Possible reuse in different applications (for example Magento 1 and Magento 2)
- Having separated bounded contexts helps to view parts of the domain isolated and without distraction.
Even in chirurgic modifications that we often have to do in Magento projects, it is worth identifying the actual logic and extracting it from the actual Magento classes.
Let me demonstrate it with a real-world example:
The requirement
In a custom product type that is based on bundle products, it should be possible to select an absolute fixed price as special price instead of a percentage. You should be able to choose between these two.
The product type already exists, using the unchanged price model and adminhtml blocks from the bundle type.
The solution
To encode the two different special price types in one field, I decided to save percentages as a negative value (-20 means 20 % discount). In the price model, I introduced a case differentiation in calculateSpecialPrice()
:
- if the special price value is positive, use absolute special prices from the default price model
- if it is negative, use relative special prices from the bundle price model
1 2 3 4 5 6 7 8 9 10 11 12 |
public static function calculateSpecialPrice($finalPrice, $specialPrice, $specialPriceFrom, $specialPriceTo, $store = null) { if ($specialPrice < 0) { return parent::calculateSpecialPrice( $finalPrice, $specialPrice + 100, $specialPriceFrom, $specialPriceTo, $store ); } return Mage_Catalog_Model_Product_Type_Price::calculateSpecialPrice( $finalPrice, $specialPrice, $specialPriceFrom, $specialPriceTo, $store ); } |
Additionally, I added a select box to the block for the special price input that toggles between two different text inputs.
To handle these inputs, a rewrite of the specialprice backend model was introduced
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public function beforeSave($product) { if (!($product instanceof Mage_Catalog_Model_Product) || $product->getTypeId() != IntegerNet_CustomBundle_Model_Product_Type::TYPE_ID ) { return parent::beforeSave($product); } parent::beforeSave($product); $attrCode = $this->getAttribute()->getAttributeCode(); switch ($product->getData($attrCode . '-type')) { case 'abs': $product->setData($attrCode, $product->getData($attrCode . '-abs')); break; case 'perc': $product->setData($attrCode, $product->getData($attrCode . '-perc') - 100); break; } return $this; } |
If we ignore the indexer where it is all about optimizing SQL queries, this worked and I could have been done with it. I confess to have built this with trial and error, not driven by automated tests.
But while these calculations made perfect sense for me at the time, I knew they would screw with a future programmer who might have to make changes or debug this code. I also knew that future programmer would likely be me, and I would not remember everything I had in mind that day.
So the least I could do was to collect the logic of the stored special price value at one place and document it properly.
Refactoring
Step 1: special price value objects
To do this, I first introduced a “SpecialPriceType” value object that represents “absolute” or “percentage”, and another value object “SpecialPrice”, which encapsulates original price, special price and all conversion logic from above.
Then, for example, the special price backend model would use named constructors like absolute($originalPrice, $specialPriceAbsolute)
and the specialPriceValueToStore($type)
method to get the value that should be saved to the database, based on the selected price type.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
final class IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType { const ABSOLUTE = 'abs'; const PERCENTAGE = 'perc'; private $type; /** * @param $type */ private function __construct($type) { $this->type = $type; } public static function absolute() { return new self(self::ABSOLUTE); } public static function percentage() { return new self(self::PERCENTAGE); } public function isAbsolute() { return $this->type === self::ABSOLUTE; } public function isPercentage() { return $this->type === self::PERCENTAGE; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
use IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType as SpecialPriceType; /** * Value object for price with special price */ final class IntegerNet_CustomBundle_Model_PriceValue_SpecialPrice { private $originalPrice; private $specialPrice; private function __construct($originalPrice, $specialPrice) { $this->originalPrice = $originalPrice; $this->specialPrice = $specialPrice; } /** * Create special price object from value in database. Negative values are percentage * discounts, positive values are absolute prices. * * @param float $originalPrice * @param float $storedSpecialPrice * @return IntegerNet_CustomBundle_Model_PriceValue_SpecialPrice */ public static function fromStoredValue($originalPrice, $storedSpecialPrice) { if ($storedSpecialPrice < 0) { return new self($originalPrice, $originalPrice * ($storedSpecialPrice + 100) /100); } return new self($originalPrice, $storedSpecialPrice); } /** * Create special price object based on absolute special price * * @param float $originalPrice * @param float $specialPriceAbsolute * @return IntegerNet_CustomBundle_Model_PriceValue_SpecialPrice */ public static function absolute($originalPrice, $specialPriceAbsolute) { return new self($originalPrice, $specialPriceAbsolute); } /** * Create special price object based on percentage special price * * @param float $originalPrice * @param float $specialPricePercentage * @return IntegerNet_CustomBundle_Model_PriceValue_SpecialPrice */ public static function percentage($originalPrice, $specialPricePercentage) { return new self($originalPrice, $originalPrice * $specialPricePercentage / 100); } /** * Returns special price as percentage of original price * * @return float */ public function specialPricePercent() { return $this->specialPrice / $this->originalPrice * 100; } /** * Returns absolute special price value * * @return float */ public function specialPriceAbsolute() { return $this->specialPrice; } /** * Returns special price value to be saved * * @param SpecialPriceType determine if value should be saved as absolute or percentage value * @return float */ public function specialPriceValueToStore(SpecialPriceType $type) { if ($type->isAbsolute()) { return $this->specialPrice; } if ($type->isPercentage()) { return $this->specialPricePercent() - 100; } throw new DomainException('Unknown special price type'); } } |
Step 2: Make price types intelligent
Now I had successfully moved the logic to one place independent of the Magento framework, but it still didn’t look as clear as I hoped and was a bit clumsy to be used. So I asked myself: what objects do we have and what are their responsibilities?
- Special Price: Value object, represents an original price / special price pair
- Special Price Type: Represents the type of the stored special price value (“absolute” or “percentage”)
It occured to me that SpecialPrice
should not have to care at all about the format we use to store it in the database. So I moved the conversion from and to stored values to the SpecialPriceType
class. Actually, I made two subclasses, SpecialPriceType_Absolute
and SpecialPriceType_Percentage
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
final class IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType_Absolute extends IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType { protected function __construct() { $this->type = self::ABSOLUTE;; } public function valueToStore($inputValue) { return $inputValue; } public function absoluteSpecialPriceFromStoredValue($storedValue, $originalPrice) { return $storedValue; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
final class IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType_Percentage extends IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType { protected function __construct() { $this->type = self::PERCENTAGE; } public function valueToStore($inputValue) { return $inputValue - 100; } public function absoluteSpecialPriceFromStoredValue($storedValue, $originalPrice) { return $originalPrice * ($storedValue + 100) / 100; } } |
Voilà: we isolated all the logic of how the special price is stored.
And below, the abstract base class. It has two named constructors that decide which subclass to instantiate: fromString()
based on the dropdown value from the form, determineFromStoredValue()
based on the stored value:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
use IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType_Absolute as Absolute; use IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType_Percentage as Percentage; /** * Special price type (absolute / percentage) * * Contains conversion logic from and to stored special price value */ abstract class IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType { const ABSOLUTE = 'abs'; const PERCENTAGE = 'perc'; protected $type; /** * Instantiate special price type based on stored value * * Values less than zero are treated as percentage values, others as absolutes * * @param float $storedValue * @return self */ public static function determineFromStoredValue($storedValue) { if ($storedValue < 0) { return new Percentage; } return new Absolute; } /** * Instantiate special price type based on constant (from user input) * * @param string $type See class constants * @return self */ public static function fromString($type) { switch ($type) { case self::PERCENTAGE: return new Percentage; case self::ABSOLUTE: default: return new Absolute; } } public function __toString() { return $this->type; } /** * Convert value from form input to value for database * * @param float $inputValue * @return float */ abstract public function valueToStore($inputValue); /** * Convert value from database to absolute special price * * @param float $storedValue * @param float $originalPrice * @return float */ abstract public function absoluteSpecialPriceFromStoredValue($storedValue, $originalPrice); } |
For convenience, I also kept the fromStoredValue()
constructor of the SpecialPrice
class but it now uses SpecialPriceType
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
use IntegerNet_CustomBundle_Model_PriceValue_SpecialPriceType as SpecialPriceType; /** * Value object for price with special price */ final class IntegerNet_CustomBundle_Model_PriceValue_SpecialPrice { /** @var float */ private $originalPrice; /** @var float */ private $specialPriceAbsolute; private function __construct($originalPrice, $specialPrice) { $this->originalPrice = $originalPrice; $this->specialPriceAbsolute = $specialPrice; } /** * Create special price object from value in database. Negative values are percentage * discounts, positive values are absolute prices. * * @param $originalPrice * @param $storedSpecialPrice * @return IntegerNet_CustomBundle_Model_PriceValue_SpecialPrice */ public static function fromStoredValue($originalPrice, $storedSpecialPrice) { return new self($originalPrice, SpecialPriceType::determineFromStoredValue($storedSpecialPrice) ->absoluteSpecialPriceFromStoredValue($storedSpecialPrice, $originalPrice)); } /** * Returns special price as percentage of original price * * @return float */ public function specialPricePercent() { return $this->specialPriceAbsolute / $this->originalPrice * 100; } /** * Returns absolute special price value * * @return float */ public function specialPriceAbsolute() { return $this->specialPriceAbsolute; } } |
Result: Plug it in
And this is what calculateSpecialPrice()
of the product type price model looks like now:
1 2 3 4 5 6 7 |
public static function calculateSpecialPrice($finalPrice, $specialPrice, $specialPriceFrom, $specialPriceTo, $store = null) { return Mage_Catalog_Model_Product_Type_Price::calculateSpecialPrice($finalPrice, SpecialPrice::fromStoredValue($finalPrice, $specialPrice)->specialPriceAbsolute(), $specialPriceFrom, $specialPriceTo, $store); } |
No more condition and the whole conversion is delegated to the framework independent code.
The same goes for the special price backend model:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public function beforeSave($product) { if (!($product instanceof Mage_Catalog_Model_Product) || $product->getTypeId() != IntegerNet_CustomBundle_Model_Product_Type::TYPE_ID ) { return parent::beforeSave($product); } parent::beforeSave($product); $attrCode = $this->getAttribute()->getAttributeCode(); $specialPriceType = SpecialPriceType::fromString($product->getData($attrCode . '-type')); $product->setData($attrCode, $specialPriceType->valueToStore( $product->getData($attrCode . '-' . $specialPriceType) ) ); return $this; } |
Conclusion
Decoupling business logic is possible and valuable, not only in complex modules but even in small modifications. It makes the code more understandable and more testable. It might take a bit longer in the first iteration and takes some more thought, but if you ever have to deal with the code again, you will thank yourself for that.

Author: Fabian Schmengler
Fabian Schmengler is Magento developer and trainer at integer_net. His focus lies in backend development, conceptual design and test automation.
Fabian was repeatedly selected as a Magento Master in 2017 and 2018 based on his engagements, active participation on StackExchange and contributions to the Magento 2 core.
Trackbacks/Pingbacks