Using Symfony Forms with Rich Domain Models

Using Symfony Forms with Rich Domain Models

With the popularisation of DDD people started shifting from anemic models with only getters and setters to a rich model describing the state changes in specific methods. This way of designing models does not play well with Symfony forms. User provided input is inherently invalid while we want to maintain certain invariants in our domain model. A common approach to overcome these limitations is to create data transfer objects our forms are then bound to. This can lead to lots of mapping & glue code that might be cumbersome to write and maintain. But couldn’t we do better? In this talk we will discuss the different aspects of a rich domain model that makes it hard to use it in conjunction with the Form component. We will then look at the possibilities to hook into the data flow of the form handling and discover how we can modify it to interact seamlessly with our model.

Bundle: https://github.com/sensiolabs-de/rich-model-forms-bundle
Demo App: https://github.com/sensiolabs-de/rich-model-forms-demo

0bee6a2886272e60be8888ae48baf42d?s=128

Christopher Hertel

September 28, 2018
Tweet

Transcript

  1. 2.

    @xabbuh && @el_stoffel Christopher Hertel Consultant & Trainer @ SensioLabs

    Symfony User Group Berlin Christian Flothmann Software Developer @ SensioLabs Symfony Core & Docs Member
  2. 15.

    @xabbuh && @el_stoffel class Product { private $id; /** *

    @Assert\Length(min=3) */ private $name; /** * @Assert\NotNull */ private $category; /** * @Assert\GreaterThan(0) */ private $priceAmount; /** Model @xabbuh && @el_stoffel src/Entity/Product.php
  3. 16.

    @xabbuh && @el_stoffel public function setPriceTax(?int $priceTax): void { $this->priceTax

    = $priceTax; } public function getPriceCurrency(): ?string { return $this->priceCurrency; } public function setPriceCurrency(?string $priceCurrency): void { $this->priceCurrency = $priceCurrency; } } Model @xabbuh && @el_stoffel src/Entity/Product.php
  4. 18.

    @xabbuh && @el_stoffel HTML Form POST /product HTTP/1.1 Host: localhost:8000

    Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[priceAmount]: 0.59 product[priceTax]: 7 product[priceCurrency]: EUR HTTP Request Form Submit
  5. 19.

    @xabbuh && @el_stoffel POST /product HTTP/1.1 Host: localhost:8000 Connection: keep-alive

    Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[priceAmount]: 0.59 product[priceTax]: 7 product[priceCurrency]: EUR HTTP Request HttpFoundation Front Controller
  6. 27.

    @xabbuh && @el_stoffel Anemic Domain Model focus on data structured

    easy to implement and to maintain contains little or no logic no guarantee to be valid or consistent
  7. 29.

    @xabbuh && @el_stoffel Rich Domain Model combines data and logic

    valid by design easy to test defined state transitions
  8. 41.

    @xabbuh && @el_stoffel Anemic Models Prototyping Ease of use Easily

    generated Rich Models Clean Code Testability Truly OOP Your Choice!
  9. 43.

    @xabbuh && @el_stoffel class Product { private $id; private $name;

    private $category; private $price; public function __construct(string $name, Category $category, Price $price) { $this->validateName($name); $this->name = $name; $this->category = $category; $this->price = $price; } public function getId(): int { return $this->id; } Model src/Entity/Product.php @xabbuh && @el_stoffel
  10. 44.

    @xabbuh && @el_stoffel } public function getName(): string { return

    $this->name; } public function rename(string $name): void { $this->validateName($name); $this->name = $name; } public function getCategory(): Category { return $this->category; } public function moveToCategory(Category $category): void { $this->category = $category; } public function getPrice(): Price Model src/Entity/Product.php @xabbuh && @el_stoffel
  11. 45.

    @xabbuh && @el_stoffel { $this->category = $category; } public function

    getPrice(): Price { return $this->price; } public function costs(Price $price): void { $this->price = $price; } private function validateName(string $name): void { if (strlen($name) < 3) { throw ProductException::invalidName($name); } } } Model src/Entity/Product.php @xabbuh && @el_stoffel
  12. 53.

    @xabbuh && @el_stoffel final class Product { public $id; /**

    * @Assert\Length(min=3) */ public $name; /** * @Assert\NotNull */ public $category; /** * @Assert\GreaterThan(0) */ public $priceAmount; /** DTO src/Form/DTO/Product.php @xabbuh && @el_stoffel
  13. 54.

    @xabbuh && @el_stoffel * @Assert\Currency */ public $priceCurrency; public static

    function fromEntity(ProductEntity $product = null): self { $self = new self(); if (null === $product) { return $self; } $self->id = $product->getId(); $self->name = $product->getName(); $self->category = $product->getCategory(); $self->priceAmount = $product->getPrice()->getAmount(); $self->priceTax = $product->getPrice()->getTax(); $self->priceCurrency = $product->getPrice()->getCurrency(); return $self; } public function toEntity(ProductEntity $product = null): ProductEntity { $price = new Price((int) $this->priceAmount, $this->priceTax, $this->priceCurrency); DTO src/Form/DTO/Product.php @xabbuh && @el_stoffel
  14. 55.

    @xabbuh && @el_stoffel return $self; } public function toEntity(ProductEntity $product

    = null): ProductEntity { $price = new Price((int) $this->priceAmount, $this->priceTax, $this->priceCurrency); if (null === $product) { return new ProductEntity($this->name, $this->category, $price); } if ($product->getName() !== $this->name) { $product->rename($this->name); } if ($product->getCategory() !== $this->category) { $product->moveToCategory($this->category); } if (!$price->equals($product->getPrice())) { $product->costs($price); } return $product; } } DTO src/Form/DTO/Product.php @xabbuh && @el_stoffel
  15. 59.

    @xabbuh && @el_stoffel HTML Formular POST /product HTTP/1.1 Host: localhost:8000

    Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[priceAmount]: 0.59 product[priceTax]: 7 product[priceCurrency]: EUR HTTP Request Form Submit
  16. 60.

    @xabbuh && @el_stoffel POST /product HTTP/1.1 Host: localhost:8000 Connection: keep-alive

    Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[priceAmount]: 0.59 product[priceTax]: 7 product[priceCurrency]: EUR HTTP Request HttpFoundation Front Controller
  17. 68.

    @xabbuh && @el_stoffel DTO Solution enables us to use Rich

    Domain Models easy to implement and maintain additional Glue Code redundant validation rules
  18. 77.

    @xabbuh && @el_stoffel HTML Formular POST /product HTTP/1.1 Host: localhost:8000

    Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[price][amount]: 0.59 product[price][tax]: 7 product[price][currency]: EUR HTTP Request Form Submit
  19. 78.

    @xabbuh && @el_stoffel POST /product HTTP/1.1 Host: localhost:8000 Connection: keep-alive

    Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[price][amount]: 0.59 product[price][tax]: 7 product[price][currency]: EUR HTTP Request HttpFoundation Front Controller
  20. 83.
  21. 84.

    @xabbuh && @el_stoffel Form Type Model HTTP Request Front Controller

    Controller Form Glue Code Domain Symfony Model Data Mapper Data Mapper
  22. 85.

    @xabbuh && @el_stoffel DataMapper Solution enables us to use Rich

    Domain Models requires advanced knowledge about Forms additional Glue Code hard to test
  23. 107.

    @xabbuh && @el_stoffel HTML Formular POST /product HTTP/1.1 Host: localhost:8000

    Connection: keep-alive Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[price][amount]: 0.59 product[price][tax]: 7 product[price][currency]: EUR HTTP Request Form Submit
  24. 108.

    @xabbuh && @el_stoffel POST /product HTTP/1.1 Host: localhost:8000 Connection: keep-alive

    Content-Length: 133 Cache-Control: max-age=0 [...] Accept-Language: en;q=0.9 product[name]: rocket product[category]: 18 product[price][amount]: 0.59 product[price][tax]: 7 product[price][currency]: EUR HTTP Request HttpFoundation Front Controller
  25. 115.

    @xabbuh && @el_stoffel RichModelFormsBundle to the rescue! generalizes the previous

    approach additional form config options tailored for rich model form needs released today
  26. 116.

    @xabbuh && @el_stoffel Final Disclaimer not feature complete covers common

    use cases we discovered needs input for more use cases no, not yet in production