-
-
Save yvoyer/a778ac744ddf10012864 to your computer and use it in GitHub Desktop.
<?php | |
/** | |
* This class is used as date_class in form component | |
*/ | |
class CalculationCommand | |
{ | |
const BASIC_TYPE = 'basic_calculaction_type'; | |
public $subTotal; | |
public $quantity; | |
} |
<?php | |
class CalculationController | |
{ | |
/** | |
* @var CalculationHandler | |
*/ | |
private $handler; | |
/** | |
* @param CalculationHandler $handler | |
*/ | |
public function __construct(CalculationHandler $handler) { | |
$this->handler = $handler; | |
} | |
/** | |
* @Route("/calculation/{type}") | |
* @Template() | |
*/ | |
public function indexAction($type) | |
{ | |
$command = $this->getFormData($type); | |
$this->handler->handle($command); // todo would probably be the command bus | |
// Get ID from entity and redirect, no return values required | |
} | |
/** | |
* @param string $type | |
* | |
* @return CalculationCommand | |
*/ | |
private function getFormData($type) | |
{ | |
// todo this could be a calculation mapper service | |
$mapping = [ | |
CalculationCommand::BASIC_TYPE => CalculationCommand::class, | |
]; | |
// Form handling generates the configured command object | |
/** | |
* @var CalculationCommand $command | |
*/ | |
$command = new $mapping[$type]; | |
$command->quantity = 12; | |
$command->subTotal = 1000; | |
// Form would configure the command with values | |
return $command; | |
} | |
} |
<?php | |
class CalculationHandler | |
{ | |
/** | |
* @var EntityManager | |
*/ | |
private $entityManager; | |
/** | |
* @var TokenStorageInterface | |
*/ | |
private $tokenStorage; | |
/** | |
* @var Session | |
*/ | |
private $session; | |
/** | |
* @var Translator | |
*/ | |
private $translator; | |
/** | |
* @param EntityManager $entityManager | |
* @param TokenStorageInterface $tokenStorage | |
* @param Session $session | |
* @param Translator $translator | |
* | |
* FIXME null values here are just to make prototype work | |
*/ | |
public function __construct( | |
EntityManager $entityManager, | |
TokenStorageInterface $tokenStorage = null, | |
Session $session = null, | |
Translator $translator = null | |
) { | |
$this->entityManager = $entityManager; | |
$this->tokenStorage = $tokenStorage; | |
$this->session = $session; | |
$this->translator = $translator; | |
} | |
/** | |
* @param CalculationCommand $command | |
*/ | |
public function handle($command) | |
{ | |
// transfer required args from command to construct. that way object is in valid state | |
$entity = new DormerCalculation();// Command could contain necessary args for this object construct | |
$entity->addPrice('total', $command->subTotal, $command->quantity); | |
$this->entityManager->persist($entity); | |
$this->entityManager->flush(); | |
// do other stuff with entity (translator etc.) | |
} | |
} |
{ | |
"name": "vendor_name/package_name", | |
"description": "description_text", | |
"minimum-stability": "stable", | |
"license": "proprietary", | |
"authors": [ | |
{ | |
"name": "author's name", | |
"email": "[email protected]" | |
} | |
], | |
"require": { | |
"phpunit/phpunit": "^5.2" | |
}, | |
"autoload": { | |
"psr-0": { | |
"": "" | |
} | |
} | |
} |
<?php | |
/** | |
* DormerCalculation | |
* | |
* @ORM\Entity | |
*/ | |
class DormerCalculation | |
{ | |
// THIS IS ONLY TO MAKE THE PROTOTYPE WORK | |
public static $hash; | |
/** | |
* @var integer $id | |
* | |
* @ORM\Column(name="id", type="integer", precision=0, scale=0, nullable=false, unique=false) | |
* @ORM\Id | |
* @ORM\GeneratedValue(strategy="IDENTITY") | |
*/ | |
private $id; | |
/** | |
* @var DormerCalculationPrice[] | |
* | |
* @ORM\OneToMany(targetEntity="DormerCalculationPrice", | |
* mappedBy="dormerCalculation", cascade="persist", indexBy="name", fetch="EAGER" | |
* ) | |
*/ | |
private $prices = []; // would need to be ArrayCollection in construct | |
public function __construct($unrelevantDependancys = []) // define the required args here | |
{ | |
$this->id = spl_object_hash($this); // FIXME For Prototype only, don't do that | |
self::$hash = $this->id; | |
// todo $this->prices = new ArrayCollection(); | |
} | |
// FIXME try not the make it public via getter | |
public function getId() | |
{ | |
return $this->id; | |
} | |
public function addPrice($key, $subTotal, $quantity) { | |
$price = new DormerCalculationPrice($this, $key, $subTotal, $quantity); | |
$this->prices[$key] = $price; | |
} | |
/** | |
* @return DormerCalculationPrice[] | |
*/ | |
public function getPrices() // would be better if private or absent, unless absolutly needed | |
{ | |
return $this->prices; // todo should be $this->prices->toArray(); | |
} | |
/** | |
* @return int | |
*/ | |
public function getTotal() | |
{ | |
$total = 0; | |
foreach ($this->prices as $price) { | |
$total += $price->getTotal(); | |
} | |
return $total; | |
} | |
} |
<?php | |
class DormerCalculationPrice | |
{ | |
private $name; | |
private $quantity; | |
private $total; | |
private $subtotal; | |
private $calculation; // probably not transferable??? so no set once created | |
/** | |
* @param DormerCalculation $calculation | |
* @param $name | |
* @param $subTotal | |
* @param $quantity | |
* | |
* I suggest to pass not nullable relations in construct, and not provide setters | |
* for attribute that don't chance or cannot be changed in the domain | |
*/ | |
public function __construct(DormerCalculation $calculation, $name, $subTotal, $quantity) | |
{ | |
$this->calculation = $calculation; | |
$this->name = $name; | |
$this->subtotal = $subTotal; | |
$this->quantity = $quantity; | |
$this->total = $this->calculateTotal(); | |
} | |
/** | |
* @return int | |
*/ | |
private function calculateTotal() | |
{ | |
return $this->subtotal * $this->quantity; //The result of your calculation | |
} | |
/** | |
* @return int | |
*/ | |
public function getTotal() | |
{ | |
return $this->total; | |
} | |
// setter should be necessary here only for attribute that can change over the life cycle of the entity | |
} |
<?php | |
final class EntityManager | |
{ | |
private $objects = []; | |
/** | |
* @param DormerCalculation $object | |
*/ | |
public function persist($object) | |
{ | |
$this->objects[$object->getId()] = $object; | |
} | |
public function flush() | |
{ | |
} | |
/** | |
* @param $id | |
* | |
* @return object | |
*/ | |
public function find($id) | |
{ | |
return $this->objects[$id]; | |
} | |
} |
<?php | |
final class WorkflowTest extends \PHPUnit_Framework_TestCase | |
{ | |
public function test_it_should_do_something() | |
{ | |
$em = new EntityManager(); | |
$controller = new CalculationController(new CalculationHandler($em)); | |
$controller->indexAction(CalculationCommand::BASIC_TYPE); | |
$this->assertNotNull(DormerCalculation::$hash); | |
/** | |
* @var DormerCalculation $createdEntity | |
*/ | |
$createdEntity = $em->find(DormerCalculation::$hash); | |
$this->assertInstanceOf(DormerCalculation::class, $createdEntity); | |
$this->assertCount(1, $createdEntity->getPrices()); | |
$this->assertContainsOnlyInstancesOf(DormerCalculationPrice::class, $createdEntity->getPrices()); | |
$this->assertSame(12000, $createdEntity->getTotal()); | |
} | |
} |
I would put all validation rules on the command (annotation). Usually the domain object encapsulates the business rules with guard methods to ensure nothing break your rule. The validation on the side of the command would just be to respect those guards.
It.
// domain object
Public function calculateStuff($inputs, $input2)
{
If ($inputs > $input2) {
throw new CalculationException::input1MustBeGreaterThanInput2 ();
}
// return result
}
// your command
// rules for form validation of command
Public $input1;
Public $input2;
That way you don't pollute your domain object with form, or validation stuff.
I also like to test all my services on their own (without symfony or db) that way it is way faster to test. And the integration in symfony is faster since all the rules are already guarded against. All you need to do is just make sure that all guardsounds are converted by validation rules on the symfony side so that the user do not experiment a 500 errors due to exception.
Remember, symfony bundle are just wrappers for your librairies, so make your library work first, THEN integrate it to symfony.
That way you do not couple your code to symfony, and if at some point you need to reuse your domain object, you can without needing the symfony fullstack.
Thanks @yvoyer . I will move my constraints from my entity to my command. This will repeat a lot of properties inside the command that already existed inside the entity, but that is okay I guess.
When working with Symfony Forms I would init the command which is the set as data_class
on the form, right?
After the form is valid I can pass the command - which was populated by the form after binding - to the handler.
Agree?
@webdevilopers exactly
Imo, It's okay not to have the validation rules on the entity, because you don't want a client to be able to use your domain object without ensuring that it's state is valid.
If you have other question feel free to ask.
π
I will update my code and add it to the gist as soon as I got it working. :)
Time to refactor my refactored code! ;)
BTW @yvoyer: What namespaces and directory structure do you suggest? What goes into the bundle, what into the Domain?
So far I would put Forms, Controller and Entity into the bundle src/AppBundle/
or src/Vendor/Bundle
. And the Entities extend Models (Interfaces).
These Models and Commands including Handler go into something like src/Vendor/
.
@webdevilopers, here what I would use as hierarchy:
src/
βββ MyDomain
βΒ Β βββ Command
βΒ Β βΒ Β βββ CalculationCommand.php
βΒ Β βββ Entity
βΒ Β βΒ Β βββ DormerCalculation.php
βΒ Β βΒ Β βββ DormerCalculationPrice.php
βΒ Β βββ Handler
βΒ Β βββ CalculationHandler.php
βββ MyDomainBundle
βββ Controller
βββ CalculationController.php
here is a link about implementing DDD in Symfony: http://williamdurand.fr/2013/08/07/ddd-with-symfony2-folder-structure-and-code-first/
So you are not using a Vendor
prefix. Regarding the link by @willdurand would you regard MyDomain
as CoreDomain
with the Calculation
folder inside or consider creating a CalculationDomain
with your structure?
My app holds 10 years of legacy code and a lot of what could have been described as bundles (User, Calculation, Superadmin, Intranet, Pricequote, Web) in symfony.
Thinking of Symfony Form integration again, what if the CREATE form and the EDIT form would have relevant differences,
would you create to commands e.g. CreateCalculationCommand
and EditCalculationCommand
?
For instance my app offers a recalculation of the original calculation that has a completely different set of properties and constraints.
Therefore I would create a RecalculateCalculationCommand
.
I see that it makes sense to create the
DormerCalculation
Entity inside theCalculationHandler
to keep the valid state.In your Controller the valid form data is passed. Where would you put your validation? Still add constraints to the entity and bind it to the form via
data_class
? But then NOT pass the populated Entity itself to the command handler but e.g. an array of the transformed entity / model?